Table des matières
5. AM2315 - Température et humidité
7. BME280 et BMP280 - Pression, humidité, température
9. ADS1115 - Lecture analogique
11. Photorésistance - luminosité
12. PIR - Détection de mouvement
14. Senseur à effet Hall numérique
1. Installation du Raspberry Pi
2. Utilitaires : des outils pour travailler
c. Transfert de fichiers via SSH (sftp)
1. Le broker MQTT, élément central du réseau MQTT
2. Création de topic et bonnes pratiques
Configurer le login du broker MQTT
3. Documentation complémentaire
1. Les possibilités offertes par l’ESP8266
2. Les plateformes ESP8266 populaires
4. Feather Huzzah ESP8266 en détail
5. Brochage du Feather Huzzah ESP8266
Charger le firmware MicroPython
1. Identifier le firmware MicroPython
1. Communiquer avec MicroPython
2. Communiquer avec un ESP8266 sous MicroPython
3. REPL : l’invite de commandes MicroPython
a. Activer WebREPL sur l’ESP8266
2. Réseau Wi-Fi visible ou masqué
5. Désactivation du point d’accès
6. Rechercher l’adresse IP d’un ESP8266
Séquence de démarrage MicroPython
3. Un fichier boot.py pour ESP8266
a. Script trop optimiste et conséquences
b. RunApp - Activation de l’application
1. Création d’une bibliothèque
2. Les bibliothèques MicroPython
a. Bibliothèques standards et microbibliothèques
b. Bibliothèques spécifiques à MicroPython
c. Bibliothèque spécifique à l’ESP8266
d. Autres bibliothèques MicroPython
e. Mécanisme de chargement d’une bibliothèque
3. Charger et exécuter un script à la volée
4. RunApp : exécution conditionnelle de main.py
5. Entrées/sorties sur ESP8266
b. Entrée numérique (pull-up interne)
c. Entrée numérique et déparasitage logiciel
f. Ajout d’entrée/sortie avec MCP23017
g. Lecture analogique avec l’ADS1115
6. Senseur et interface sur ESP8266
a. Senseur PIR - senseur de proximité
f. BME280 - température, humidité et pression barométrique
1. Publication MQTT sous MicroPython
2. Souscription MQTT sous MicroPython
3. Fonction run_every pour Asyncio
4. Plus d’informations sur Asyncio
1. Prérequis et configurations
4. Télécharger et préparer le code des objets IoT
Fonctionnement général d’un objet IoT
3. RunApp et la LED d’activité
5. Les tâches et fonctions asynchrones des objets IoT
Objet 1 : Météo cabane de jardin
4. Senseur PIR - variables et utilisation
5. Senseur PIR - la fonction pir_activated
6. Senseur PIR - la fonction pir_alert
7. Senseur PIR - la fonction pir_update
Objet 3 : Surveillance de la véranda
4. La fonction check_contact()
5. La fonction check_mqtt_sub()
7. La fonction chaud_exec_cmd()
1. Pourquoi utiliser une base de données ?
2. Quel moteur de base de données ?
3. Principe de fonctionnement de push-to-db
2. Classe de stockage, type de données et affinité
c. Affinité de type pour les colonnes
d. Résolution de l’affinité de type
3. Affinité, expressions, comparaison et tri
b. Comparaison, tri et groupage
4. Clé primaire et auto-incrément
b. Table rowid et clé primaire
5. SQLite3 et accès concurrents
b. Installer le support Python
a. Documentation SQL pour SQLite
b. Commandes de l’interpréteur SQLite
a. Opération de lecture SQLite
b. Opération d’insertion SQLite
Approches techniques de push-to-db
1. Approche base de données de push-to-db
a. topicmsg - dernier message reçu
b. ts_xxx - historique de messages
2. Approche logicielle de push-to-db
a. Diagramme des classes (partie 1)
b. Fichier de configuration de push-to-db
c. Diagramme des classes (partie 2)
1. Les répertoires de stockage de push-to-db
2. Création des tables de push-to-db
4. Le script d’installation de push-to-db
1. Logger et fichier de configuration
Exécution du script push-to-db
Service systemd pour push-to-db
1. Quand démarrer le service ?
3. Configurer, démarrer, contrôler
3. Les nombreuses extensions Flask
4. Application Flask en production
b. Connexion à la base de données
c. Organisation du mini-projet
12. Ressources et documentations
a. Créer une application Flask
b. Test avec serveur web Flask et string Python
c. Test en console et string Python
d. Utiliser le projet Jinja Live Parser
a. {{ … }} : évaluation d’expression
b. {% … %} : instructions de contrôle de flux
c. {# … #} : insertion de commentaire
b. Heritage-app : l’héritage Jinja par la pratique
4. Fonctionnalités du projet Dashboard
2. Utilisation du template de base
1. Base de données dashboard.db
a. Schéma de la base de données
c. Création des tables de Dashboard
d. Copie de la base de données
2. Fichier de configuration de Dashboard
Détails de l’application Flask
a. La fonction get_db( db_key ) multi bases de données
b. Les classes DBHelper de Dashboard
c. Exemple : liste des topics disponibles pour Dashboard
d. Exemple : extraction de l’historique dans Dashboard
e. Affichage d’un tableau de bord
4. Les filtres Jinja personnalisés
5. Affichage du tableau de bord
d. La macro select_color (édition d’un bloc)
1. Développements complémentaires
b. Bloc et paramètres additionnels
b. Ajouter le nouveau type de bloc
b. MQTT en JavaScript et WebSocket
c. Activer le support WebSocket sur Mosquitto
d. Tester le client MQTT JavaScript
e. Mille milliards de mille sabords !
La liste ci-dessous reprend différents éléments exploités dans le projet ou dans l’ouvrage. Chaque élément est accompagné d’une petite description.
Raspberry Pi 3
Le Raspberry Pi est certainement le nano-ordinateur le plus célèbre du monde.
À peine plus grand qu’une carte de crédit, le Raspberry Pi est un formidable outil d’apprentissage et une excellente base pour le développement de solutions amateur et semi-professionnelles.
Propulsé par 4 cœurs à 1 GHz, 1 gigaoctet de RAM et Linux, le Raspberry Pi dispose d’interfaces Wi-Fi et Bluetooth, de 4 ports USB, d’une interface Ethernet et d’un GPIO à 40 broches permettant de brancher une multitude de périphériques et de matériels électroniques.
Feather ESP8266 Huzzah
L’ESP8266 est un microcontrôleur Wi-Fi et probablement la plateforme la plus célèbre après l’Arduino UNO (la référence en programmation de microcontrôleurs dans le monde des makers).
L’ESP8266 basé sur le module ESP12S se retrouve sur de nombreuses plateformes de développement comme Feather, Wemos, NodeMCU. C’est la version Feather ESP8266 d’Adafruit Industries qui a été sélectionnée dans cet ouvrage. Adafruit Industries dispose d’un large réseau de distribution et de produits fiables. Le module Feather exploite un module ESP certifié et les cartes restent identiques d’une livraison à l’autre (ce qui n’est pas forcément le cas des produits directement commandés en Chine). Autre point important, cette version Feather de l’ESP8266 dispose d’un auto-reset pour activer le mode flash sans avoir à manipuler de bouton.
Feather est également un écosystème de cartes et d’extensions plus intéressantes les unes que les autres. La gamme Feather d’Adafruit est accessible directement sur : https://www.adafruit.com/category/943
Hormis des produits de qualité, Adafruit Industries dispose d’une grande communauté et d’un excellent support technique.
Les modules relais pré-assemblés permettent de commander facilement des objets de notre quotidien. Ils agissent comme des interrupteurs commandés par un microcontrôleur ou un nano-ordinateur.
Si le module relais permet de travailler facilement avec des appareils connectés sur le réseau domestique, il est important de mentionner que l’utilisation d’une tension supérieure à 30 V peut devenir extrêmement dangereuse. Les risques d’électrocution sont réels et peuvent, dans certains cas, conduire à un arrêt cardiaque ! La manipulation de circuits haute tension, y compris du réseau domestique, doit être réservée aux personnes disposant du savoir-faire adéquat.
Senseur d’humidité DHT11
Le DHT11 est un senseur d’humidité très bon marché souvent utilisé par les makers. Il permet de relever l’humidité relative entre 20 et 80 %. Le senseur mesure la température pour ajuster les mesures effectuées, information également fournie par le senseur. À noter que l’humidité relative dépend de la température, car l’air peut emmagasiner plus d’humidité si la température augmente.
Le vieillissement de ce type de senseur est une caractéristique méconnue par ses utilisateurs. En effet, la mesure de l’humidité est réalisée par un effet capacitif, ce qui implique qu’une partie du senseur doit être en contact avec l’air ambiant. Il y a donc des phénomènes d’oxydation qui entrent en compte et qui provoquent le vieillissement du senseur.
Senseur AM2315
Le senseur AM2315 fonctionne de façon similaire au DHT11 à la différence que celui-ci offre un relevé entre 0 et 100 % d’humidité relative et embarque également un senseur de température numérique. À placer à l’abri des intempéries (il n’est pas weather proof), ce senseur peut effectuer des relevés en extérieur.
Senseur DS18B20
Le senseur DS18B20 est un senseur numérique utilisant un bus de données 1-Wire qui permet de placer plusieurs senseurs sur un même bus. Le DS18B20 est populaire dans le monde des makers et régulièrement exploité dans le monde professionnel. Ce composant est utilisé sur une grande variété de plateformes. Disponible sous forme d’un composant brut ressemblant à un transistor, le DS18B20 est également distribué sous forme de sonde (dans un capuchon en inox) permettant de relever la température en de nombreux endroits.
Senseur BME280 en breakout
Senseur BMP280 en breakout
Le BMP280 est un senseur environnemental permettant de relever la température et la pression atmosphérique. C’est donc un composant idéal pour réaliser des relevés météorologiques.
Le BME280 est une évolution du BMP280 permettant, en plus, de faire un relevé d’humidité relative.
Senseur TSL2561 en breakout
Le TSL2561 permet d’effectuer un relevé de luminosité mesuré en Lux. Le TSL2561 utilise un double senseur interne autorisant un relevé du spectre entier et du spectre infrarouge. Ce senseur dispose donc des informations adéquates pour être capable de fournir une réponse proche de celle de l’œil humain.
Convertisseur ADC ADS1115 en breakout
L’ADS1115 est un convertisseur analogique vers numérique, ce qui lui permet de lire des tensions analogiques. L’ADS1115 dispose d’un amplificateur à gain programmable, ce qui permet à la carte de lire de très faibles tensions. L’ADS1115 propose des relevés d’une excellente précision et supporte également un mode différentiel permettant de lire la différence de tension entre deux broches du convertisseur.
L’ADS1115 est un composant idéal pour offrir des entrées analogiques à un Raspberry Pi ou un ESP8266. Sur un Arduino, il offrira des entrées analogiques avec une bien meilleure précision que celle offerte par le microcontrôleur Atmel.
Senseur TMP36
Le TMP36 est un senseur de température analogique bon marché et très populaire dans le monde Arduino. Ce composant fournit une tension analogique proportionnelle à la température. Le TMP36 s’utilise conjointement avec un ADS1115 sur un Raspberry Pi ou un ESP8266.
Photorésistance
La photorésistance, également appelée LDR, est un composant dont la résistance varie en fonction des conditions de luminosité. Un tel composant n’offre pas de mesure précise, mais permet de relever des conditions de luminosité relatives. Il peut être utilisé pour savoir si la lumière est allumée dans une pièce, s’il fait jour ou nuit ou toute autre mesure impliquant une forte modification des conditions de luminosité.
Senseur PIR
Le senseur PIR est utilisé pour réaliser la détection de mouvement. Celui employé dans cet ouvrage est un senseur autonome avec sortie numérique. Ce modèle est très simple à utiliser, il active une sortie pendant plusieurs secondes lorsqu’un mouvement est détecté. Un tel senseur est généralement équipé de deux potentiomètres : un premier potentiomètre permet de régler la sensibilité du senseur tandis que le deuxième potentiomètre règle le temps d’activation de la sortie numérique.
Contact magnétique
Le contact magnétique est composé de deux éléments : un interrupteur reed (sensible au champ magnétique) et un aimant. Lorsque l’aimant s’éloigne, l’interrupteur magnétique s’ouvre, ce qui permet d’interrompre un circuit électrique. Lorsque l’aimant revient près de l’interrupteur alors celui-ci se referme.
Les contacts magnétiques sont utilisés pour détecter l’ouverture d’une porte, d’un tiroir, d’une cache secrète, etc.
Senseur à effet Hall
Le senseur à effet Hall permet de détecter la présence d’un champ magnétique. Ce senseur existe avec une sortie analogique ou une sortie numérique. Un senseur analogique permet de faire des relevés d’intensité de champ magnétique (hors cadre du présent ouvrage) tandis que le senseur numérique permet de relever la présence (ou l’absence) du champ magnétique.
Le senseur à effet Hall numérique s’utilise généralement avec un aimant et permet de réaliser des interrupteurs sans contact. Il est ainsi possible de réaliser une fin de course avec un aimant, un compte tour, un détecteur de niveau d’eau en plaçant l’aimant sur un flotteur, un détecteur sans contact (très pratique pour détecter l’ouverture d’une poubelle) ou un interrupteur masqué.
Le code source du projet est disponible sous forme d’une archive depuis la page Informations générales.
Cette archive contient une version figée du projet dans l’état où il était au moment de l’édition du présent ouvrage.
L’archive contenant le code source
Cette version correspond scrupuleusement aux explications disponibles dans l’ouvrage.
L’archive peut être extraite dans le répertoire utilisateur du Raspberry Pi (soit /home/pi/) à l’aide de la commande :
unzip -e LFPYRASPFL.zip
Le projet a entièrement été développé dans le dépôt la-maison-pythonic disponible sur GitHub. Ce projet connaîtra certainement d’autres évolutions après la parution de l’ouvrage.
Durant la lecture de l’ouvrage, il est recommandé d’utiliser l’archive disponible depuis la page Informations générales.
Après la lecture de l’ouvrage, le lecteur pourra profiter des dernières avancées en téléchargeant la version disponible sur GitHub.
La version GitHub est disponible ici : https://github.com/mchobby/la-maison-pythonic
GitHub du projet « la-maison-pythonic »
Le contenu du GitHub peut être cloné sur le Raspberry Pi avec la commande :
git clone https://github.com/mchobby/la-maison-pythonic.git
Clonage du projet GitHub sur le Raspberry Pi
Ce livre utilise un Raspberry Pi comme élément central du développement. Sa configuration est donc un point important.
Pour simplifier les étapes de configuration, le Raspberry Pi sera démarré avec un système d’exploitation Raspbian (Linux) pleinement fonctionnel et donc avec un environnement de bureau, ce qui est plus confortable pour les nouveaux venus.
Cela étant, tout au long du livre, la ligne de commande et la connexion SSH seront surtout exploitées. SSH permet de disposer d’une ligne de commande sur le Raspberry Pi depuis un ordinateur distant.
Préparer la carte micro SD
Flasher la carte micro SD avec le système d’exploitation Raspbian Stretch (ou plus récent). Le système d’exploitation peut être téléchargé depuis le site de la fondation Raspberry Pi (https://www.raspberrypi.org). Pour flasher la carte SD, le logiciel Etcher (https://etcher.io/) est un excellent outil libre fonctionnant sur les systèmes d’exploitation Linux, Windows et macOS.
Flasher le système d’exploitation Raspbian sur la carte micro SD
Premier démarrage et configuration de base
Brancher un clavier et une souris. Insérer la carte micro SD dans le Raspberry Pi, brancher un moniteur HDMI (ou télévision) et le câble réseau puis, finalement, mettre le Raspberry Pi sous tension.
Le système d’exploitation démarre, affiche différents messages puis finalement l’environnement graphique et le bureau Pixel.
Les versions récentes de Pixel proposent de configurer les paramètres régionaux dès le premier démarrage, ce qui est très pratique.
Invitation à saisir les paramètres régionaux au premier démarrage
Il est possible de redémarrer l’outil de configuration à tout moment en saisissant sudo piwiz.
Le premier écran concerne les paramètres régionaux.
Sélectionnez les paramètres adéquats avant de passer à l’écran suivant en pressant le bouton Next.
Sélectionner les paramètres régionaux
Vient ensuite l’initialisation du mot de passe de l’utilisateur « pi » (utilisateur par défaut).
Saisie du mot passe de l’utilisateur pi
Il est important de saisir un mot de passe suffisamment long et complexe. La case à cocher Hide Passwords peut être décochée pour afficher le mot de passe saisi au clavier. Cela permet, entre autres, de vérifier que la disposition des touches du clavier est correcte.
La configuration se poursuit avec la sélection d’un réseau Wi-Fi. Ce point est ignoré étant donné que le Raspberry Pi, utilisé comme serveur, sera raccordé au réseau par l’intermédiaire d’une connexion Ethernet filaire.
Pressez le bouton Skip.
Configuration Wi-Fi du Raspberry Pi
La dernière étape de la configuration propose de Vérifier la disponibilité de mise à jour. Il est vivement conseillé de procéder aux mises à jour en pressant le bouton Next.
Vérification des mises à jour
Cette opération peut prendre plusieurs dizaines de minutes. Une première mise à jour de 30 à 40 minutes n’a rien d’exceptionnel ! C’est le moment de profiter d’un morceau de tarte aux framboises et d’une bonne tasse de café.
L’économiseur d’écran étant actif, l’écran peut devenir noir durant le processus de mise à jour. Presser la touche [Shift] permet de réactiver l’affichage.
Une fois la mise à jour achevée, une boîte de dialogue annonce que le système est à jour.
Fin de la mise à jour
Une fois la mise à jour terminée, l’utilisateur est invité à redémarrer son Raspberry Pi.
Invitation à redémarrer
Pressez le bouton Reboot pour redémarrer le Raspberry Pi.
Adresse IP fixe
La résolution du nom d’hôte n’est pas toujours une science exacte sur les réseaux domestiques propulsés par des box Internet. Si le fait de pouvoir accéder à une machine distante sur la base de son nom est fiable la plupart du temps, il arrive parfois que - sans aucune raison et pour une durée indéterminée - ce service devienne totalement instable.
Le Raspberry Pi servant aussi de serveur pour collecter les données des objets Internet, il est vivement recommandé de lui assigner une adresse IP fixe. D’autant plus que mDns ne sera pas disponible sur nos objets IoT.
L’assignation de l’adresse IP fixe du Raspberry Pi peut se faire :
1. | Par l’intermédiaire du serveur DHCP qui assigne l’adresse IP fixe pour l’adresse MAC du Raspberry Pi (voir la documentation du modem-routeur). |
2. | En modifiant la configuration IP directement sur le Raspberry Pi (point abordé ci-dessous). |
Lorsque le Raspberry Pi est connecté sur le réseau Ethernet, le coin supérieur droit du bureau affiche une icône contenant une double flèche. Cette icône permet d’accéder à la configuration de la connexion réseau (voir l’entrée Wireless & Wired Network Settings dans le menu contextuel).
Menu contextuel pour accéder à la configuration réseau
Dans la fenêtre de configuration des préférences réseau, il est nécessaire de sélectionner l’interface eth0 correspondant à la connexion filaire.
Configuration réseau
Le réseau local utilisé exploite des adresses de classe C avec une adresse de base 192.168.1.x. Le modem-routeur utilise l’adresse 192.168.1.1.
L’adresse IP statique du Raspberry Pi est choisie arbitrairement dans la gamme d’adresses disponibles. Dans le cadre de cet ouvrage (et les codes des exemples), l’adresse choisie est 192.168.1.210. L’adresse du routeur 192.168.1.1 est également mentionnée pour permettre au Raspberry Pi d’accéder aux ressources disponibles sur Internet, ce qui permet d’installer des paquets logiciels.
Il peut être nécessaire d’adapter les adresses IP utilisées en fonction de la configuration du réseau en cours d’utilisation.
Redémarrez le Raspberry Pi après avoir fixé l’adresse IP.
Activer le serveur SSH
Après le redémarrage du Raspberry Pi, les premières choses à faire sont d’activer le serveur SSH et d’altérer la configuration du système.
Le serveur SSH permet d’établir une ligne de commande à distance depuis un ordinateur, un environnement de travail souvent beaucoup plus confortable qu’un clavier et une souris branchés sur le Raspberry Pi où il faut saisir toutes les commandes à la main.
Pressez l’icône présentant les symboles « >_ » dans la barre de menu pour démarrer un terminal.
Activer un terminal dans l’environnement bureau
Ensuite, saisissez la commande permettant de démarrer l’utilitaire de configuration.
sudo raspi-config
Puis sélectionnez le point de menu Interfacing Options (options d’interfaçage).
Menu principal de raspi-config
Puis sélectionnez l’entrée SSH pour activer le serveur SSH.
Options d’interfaçage de raspi-config
L’outil de configuration demande s’il faut activer le serveur SSH (Would you like the SSH server to be enabled?).
Sélectionnez le bouton <Oui> pour activer le serveur SSH.
Activation du serveur SSH dans raspi-config
Confirmation d’activation du serveur SSH sur le Raspberry Pi
Une fois le service SSH activé et le Raspberry Pi redémarré, il est possible de reléguer le Raspberry Pi sur une étagère sans moniteur, ni clavier, ni souris.
Quelques autres options vont être modifiées dans l’utilitaire raspi-config avant de redémarrer le Raspberry Pi.
Modifier le nom d’hôte
Par défaut, le nom d’hôte du Raspberry Pi sur le réseau est « raspberrypi ».
Cette configuration par défaut est tout à fait correcte tant qu’il n’y a qu’un seul Raspberry Pi branché sur le réseau. Dans le cas contraire, plusieurs machines portent le même nom, ce qui complique singulièrement l’identification d’une machine précise.
Dans le cadre de ce projet, le nom d’hôte du Raspberry Pi est fixé à « pythonic ». Il est vivement conseillé d’utiliser ce nom d’hôte durant la phase d’apprentissage.
Il permet d’établir une connexion SSH, une connexion FTP ou une connexion web en précisant le nom d’hôte sur le réseau local (ex. : pythonic.local) plutôt que l’adresse IP (ex. : 192.168.1.210).
Sélectionnez l’option Network Options dans le menu principal de raspi-config.
Sélection des options réseau dans raspi-config
Puis sélectionnez l’option Hostname pour modifier le nom d’hôte pour le Pi.
Sélection de l’option Hostname
Modification du nom d’hôte
Options de démarrage
La configuration du Raspberry Pi se poursuit afin que celui-ci soit plus en adéquation avec les développements qui s’annoncent. En effet, un bureau graphique est peu pertinent dans le cadre du présent ouvrage. Il sera donc désactivé et la mémoire GPU réduite au strict minimum.
Sélectionnez l’entrée Boot Options (option de démarrage) dans le menu principal.
Options de démarrage dans Raspi-Config
Puis sélectionnez l’entrée Desktop / CLI (Bureau/Interface en ligne de commande) pour configurer l’interface de démarrage du Raspberry Pi.
Sélection de l’interface principale du Raspberry Pi dans raspi-config
Enfin, sélectionnez l’entrée Console afin de ne plus démarrer l’interface graphique tout en préservant un login protégeant le système.
Activation de la console dans raspi-config
Il est possible de démarrer l’environnement graphique à tout moment en saisissant la commande startx dans le terminal.
Réduire la mémoire GPU
La mémoire allouée au GPU (processeur graphique) peut être réduite au strict minimum étant donné que l’interface graphique n’est pas utilisée. Cela fait autant de mémoire récupérée pour le fonctionnement des processus.
Sélectionnez l’entrée Advanced Options (options avancées) dans le menu principal de raspi-config.
Menu principal de raspi-config
Sélectionnez l’entrée Memory Split (séparation de la mémoire) dans les options avancées.
Options avancées du menu raspi-config
Dans l’écran de configuration Memory Split, saisissez la valeur minimale autorisée, soit 16 Mb avant de confirmer la valeur avec le bouton Ok.
Saisie de la quantité de mémoire allouée au GPU
Redémarrer le Raspberry Pi
Une fois la configuration achevée, l’outil raspi-config propose de redémarrer le système puisque des paramètres importants ont été modifiés.
Outil raspi-config proposant le redémarrage du Pi
Après ce dernier redémarrage, le Raspberry Pi est prêt pour débuter la découverte du projet.
Une fois SSH activé, plusieurs outils peuvent être exploités pour épauler les développements sur Raspberry Pi.
Windows & Linux
PuTTY est un émulateur de terminal (logiciel libre). Il supporte de nombreux protocoles comme rlogin, Telnet et SSH. PuTTY a l’avantage de supporter les connexions TCP et les liaisons séries.
Connexion SSH sur Raspberry Pi via PuTTY
PuTTY fonctionne aussi bien sous Windows que sous Linux.
PuTTY peut être téléchargé depuis https://www.putty.org/.
Linux et Mac OSX
Pour les systèmes d’exploitation Linux et Mac, l’utilitaire en ligne de commande ssh peut être utilisé dans un terminal pour établir une connexion SSH avec un système distant.
La syntaxe à utiliser est sshutilisateur@hote, ce qui se traduit par ssh pi@pythonic.local ou ssh pi@192.168.1.210 pour atteindre le Raspberry Pi fraîchement installé.
Connexion SSH avec le Raspberry Pi depuis un terminal Linux
Hormis la saisie de lignes de commande, la consultation de fichiers journaux, la connexion SSH est très utile pour modifier des paramètres dans les fichiers de configuration. C’est là qu’intervient Nano, un outil qui deviendra vite indispensable !
Nano est un mini éditeur de texte simple et efficace qui fonctionne en mode terminal et donc aussi via SSH. Nano affiche le contenu du fichier en plein écran, permet de naviguer dans le contenu en utilisant le curseur clavier.
Nano n’inclut pas de menu dans son interface, mais utilise des combinaisons de touches pour offrir des options de commandes. Par exemple, la notation « ^X Quitter » indique qu’il faut presser simultanément les touches [Ctrl] X pour quitter l’éditeur.
Pour lancer l’éditeur de texte, il suffit de saisir la commande nano nom_de_fichier dans le terminal.
L’exemple ci-dessous montre une fenêtre de terminal présentant une connexion ssh vers pythonic.local (le Raspberry Pi) où l’utilitaire nano édite le contenu du fichier dashboard.service.sample avec la commande :
nano la-maison-pythonic/python/dashboard/install
Commande saisie dans la connexion SSH établie avec le Raspberry Pi.
Éditeur de texte Nano dans un terminal
La popularité de Nano fait qu’il est facile de trouver des informations sur Internet, informations qui peuvent être complétées par les pages de manuel (voir commande man nano) ou le message d’aide de Nano (voir commande nano --help).
Des utilitaires libres comme FileZilla et Bitvise SSH Client permettent de transférer facilement des fichiers via SSH. Ces utilitaires établissent une connexion SSH avec l’hôte distant puis ils permettent la navigation dans le système de fichiers hôte et offrent des services d’envoi ou de récupération de fichiers.
Aucune installation spécifique n’est requise côté Raspberry Pi (dit « côté serveur »). Les clients FileZilla et Bitvise peuvent être utilisés directement avec le Pi.
Le transfert de fichiers est un complément indispensable pour faciliter le développement et la maintenance à distance.
Linux, Windows et Mac OSX
Le client FileZilla est disponible pour une grande variété de plateformes en libre téléchargement sur https://filezilla-project.org.
Une fois installé, établir une connexion avec le Raspberry Pi fraîchement installé se fait en saisissant les paramètres suivants :
Connexion sur le Raspberry Pi à l’aide de FileZilla
C’est la mention du port 22 qui fait basculer FileZilla en FTP via SSH (sftp).
La mention de l’hôte pythonic.local peut être remplacée par l’adresse IP du Raspberry Pi (soit 192.168.1.210 dans le cas présent).
Interface de FileZilla
Une fois la connexion établie, FileZilla propose une interface en deux volets :
•.Le volet gauche affiche les répertoires et les fichiers locaux.
•.Le volet droit affiche les répertoires et les fichiers du système distant.
Le transfert de fichiers et de répertoires est déclenché par un simple glissé/déposé.
FileZilla propose également une option de mise à jour automatique vers le système distant lorsque le fichier local est modifié.
Windows uniquement
Bitvise SSH Client pour Windows est un logiciel offrant à la fois un client SSH et un outil de transfert SFTP.
Déjà utilisé dans le cadre professionnel, cet outil gratuit est une alternative intéressante à FileZilla.
Pour plus d’informations, voir https://www.bitvise.com/ssh-client-download.
Les heureux possesseurs d’une machine Linux pourront profiter d’une fonctionnalité très pratique : le montage de système de fichiers via SSH.
sshfs (secure file system), permet d’accéder à un système de fichiers distant à partir d’un répertoire local. De façon totalement transparente pour l’utilisateur, toute opération réalisée dans le répertoire local est en fait effectuée sur le système de fichiers de la machine distante.
Cela permet de travailler avec l’environnement de développement de l’ordinateur local sur les fichiers stockés dans l’hôte distant. Une bonne partie du code du dashboard et de push-to-db a été développée avec l’aide de sshfs.
En ligne de commande
Les lignes de commandes suivantes permettent de créer un répertoire utilisateur nommé « pythonic » puis de monter le système de fichiers distant via sshfs dans le répertoire « pythonic ».
$ cd ~
$ mkdir pythonic
$ sshfs -o idmap=user pi@192.168.1.210:/home/pi pythonic
pi@192.168.1.210’s password: *******
$ ls pythonic
Dans la commande sshfs, le paramètre pi@192.168.1.210:/home/pi mentionne l’utilisateur (pi), la machine distante (192.168.1.210), le répertoire de destination sur l’hôte (/home/pi) et le paramètre pythonic correspond au répertoire local sur lequel le système de fichiers distant sera monté.
À noter que le mot de passe de l’utilisateur pi sur l’hôte distant doit être saisi afin d’établir la connexion.
Une fois le mot de passe saisi, le système de fichier distant est accessible en explorant le contenu du répertoire « pythonic ».
Montage et exploration d’un système de fichiers distant via sshfs
Le système de fichiers peut être démonté à tout moment en utilisant la commande fusermount -u pythonic.
Via l’interface graphique
Certains environnements Linux permettent de monter un système de fichiers sshfs. Par exemple, le navigateur de fichiers Linux Mint offre un point de menu Se connecter à un serveur....
Point de menu pour réaliser une connexion sshfs
Ce qui affiche une boîte de dialogue permettant d’établir une connexion vers différents types de systèmes de fichiers, dont « ssh ».
Dans la capture ci-dessous, la connexion est configurée à l’identique de l’exemple en ligne de commande.
Configuration du montage sshfs
Ce qui rend le système distant accessible depuis le navigateur de fichiers.
Affichage du contenu distant
Bien que hors sujet, il existe également la possibilité de prendre le contrôle à distance du bureau Raspberry Pi en mode graphique à l’aide de VNC (Virtual Network Computing).
L’utilitaire raspi-config contient un point d’entrée VNC dans le menu Interfacing Options. Cette option permet d’activer un serveur VNC au démarrage du Raspberry Pi.
Menu principal de l’utilitaire
Activation du serveur VNC (point P3) dans l’utilitaire raspi-config
Grâce à VNC, il est possible de contrôler le bureau Raspberry Pi à distance en utilisant un client VNC.
L’utilisation de VNC est développée en détail dans le livre de référence « Raspberry Pi 3 et Pi Zero » au chapitre Se connecter à distance au Raspberry Pi. Livre de François Mocq, paru aux Éditions ENI.
Le projet met en œuvre des objets IoT effectuant des mesures à l’aide de composants électroniques, mesures qui se traduiront par des relevés télémétriques.
Les données collectées sont les suivantes :
•.pression
•.température (eau, air)
•.luminosité (lux)
•.humidité relative
•.présence active
•.éclairage actif
•.détection d’ouverture de porte
Derrière MQTT et le « broker MQTT » se cache une technologie très intéressante et plutôt simple à appréhender.
Étant donné que le broker MQTT est un élément central du projet, ce chapitre va s’attarder sur les concepts importants permettant d’appréhender un système MQTT dans son ensemble. La connaissance acquise sera facilement ré-exploitable dans des projets personnels et professionnels.
Le broker MQTT est un élément logiciel qui permet de mettre en relation des sources de données (senseur, logiciel, etc.) désirant publier des informations vers des clients qui utilisent un mécanisme de souscription pour recevoir ces informations.
Réseau MQTT
MQTT est un protocole conçu au début des années 2000 pour des réseaux de communications à faible bande passante (ligne téléphonique, transmission radio) et temps de latence important. MQTT se présente surtout comme un élément de base fiable permettant d’échafauder des solutions robustes.
Le graphique ci-dessus reprend des sources publiant des données/messages vers un broker. Ce dernier agit comme un distributeur et renvoie ces données/messages vers les clients ayant demandé à être notifiés par souscription.
La fiabilité et la robustesse de MQTT sont dues aux choix clés opérés durant la conception du protocole :
•.La simplicité. Un protocole simple, efficace et sans fonctionnalité avancée qui facilite son implémentation dans de nombreux environnements. Cette simplicité est un des éléments clés de l’adoption massive de MQTT.
•.La légèreté. Le transfert de données utiles doit éviter, au maximum, la surcharge de données avant l’émission sur le réseau. Le protocole doit utiliser aussi peu de bande passante que possible et être suffisamment léger pour ne pas impacter les performances du réseau. MQTT minimise son empreinte sur le réseau.
•.Sans administration. MQTT est conçu pour limiter (voire éliminer) les tâches d’administration et de configuration. Il est possible de mettre en place un réseau MQTT fonctionnant « Out-Of-The-Box ». Cela n’exclut pas pour autant des produits fournissant des possibilités d’administration, de contrôle et de sécurisation avancés autour de MQTT.
•.Mécanisme de publication/souscription. L’échange de messages en utilisant le modèle publisher/subscriber convient particulièrement bien aux transmissions d’informations télémétriques. Les senseurs ont juste besoin d’accéder au broker MQTT (via le réseau) pour y publier des informations. Les applications de surveillance se connectent sur ce même broker MQTT pour demander à être notifiées de la parution de ces informations (mécanisme de souscription).
•.Résistant aux instabilités du réseau. Il n’est pas rare d’être confronté à des coupures de communication sur des réseaux à faible débit et/ou haute latence (ex. : modem) ou des réseaux peu fiables (en milieu rural). MQTT est capable d’anticiper les interruptions de communication en mettant en place un message Testament transmis aux souscripteurs par le broker lorsqu’une interruption de connexion réseau intervient avec un émetteur (senseur) publiant des informations. Le Testament est communiqué au broker par l’émetteur au moment de sa connexion sur le broker. Cette fonctionnalité est communément appelée « Will » ou « Last Will » (derniers vœux) dans la littérature MQTT. Ce procédé simple et astucieux permet d’utiliser MQTT sur des réseaux économiques à faible coût de maintenance.
•.Détection d’interruption de session. Découlant directement du procédé de testament décrit ci-dessous, les clients ont l’opportunité d’être informés d’une éventuelle rupture de connexion durant un processus de publication des informations.
•.Pas de typage de donnée. MQTT ne fait qu’échanger des informations sous forme de messages. Il ne prend pas en charge les données typées. Pour MQTT, ce ne sont que des messages et il revient aux différents intervenants d’utiliser un même format d’échange. Cette approche d’une grande simplicité offre également beaucoup de latitude durant la phase de conception d’un projet.
•.Support de qualité de service. MQTT prévoit le support de qualité de service durant l’échange des messages si les différents environnements impliqués le permettent. Ce point est abordé plus en détail dans ce chapitre.
Un broker MQTT est un logiciel permettant d’échanger des messages entre différents intervenants en utilisant le protocole MQTT. MQTT est l’acronyme de MQ Telemetry Transport, un protocole léger destiné au transport de données télémétriques (pression, température, événement…) de machine à machine. MQTT s’appuie sur le protocole TCP/IP utilisé pour les communications sur les réseaux locaux et Internet. La simplicité du protocole MQTT en fait un candidat idéal pour les projets IoT (Internet of the Things pour Internet des objets) et, de fait, il existe de très nombreuses implémentations du protocole MQTT supportées par de nombreuses plateformes matérielles, de nombreux systèmes d’exploitation et de nombreux langages de programmation.
Exemple d’échanges de messages entre différents intervenants
Le protocole est basé sur le modèle publisher/subscriber (publication/souscription). Dans ce modèle de communication, les relevés télémétriques (les mesures) sont publiés sous forme de messages dans des sujets (de discussion) sur le broker MQTT. Les clients intéressés par ces informations souscrivent un abonnement sur ledit sujet afin d’être notifiés du contenu des différentes publications. Pour terminer, le protocole MQTT prévoit une notation hiérarchique pour organiser les sujets (cette hiérarchie permet de distinguer les différentes données télémétriques les unes des autres).
Les explications qui suivent reprennent principalement la terminologie anglaise, termes qui sont largement employés dans les différents logiciels, les interfaces de programmation et la documentation en ligne.
Les échanges MQTT font intervenir les concepts de :
•.publisher (une source publiant des données),
•.subscriber (un souscripteur désirant recevoir les informations publiées sur un sujet),
•.topic (un sujet),
•.broker MQTT.
Le fonctionnement du système dans son ensemble peut être, dans une certaine mesure, comparé à l’utilisation d’un forum de discussion sur Internet.
Dans le cas d’une comparaison avec les forums, le broker sera la partie logicielle du serveur gérant les connexions, les comptes utilisateurs, les différents sujets. Ces sujets sont organisés et hiérarchisés en catégories et sous-catégories. Le logiciel serveur prendra en charge la réception d’un message sur le sujet donné et s’occupera de la propagation dudit message auprès des utilisateurs ayant demandé à être notifiés de toute publication sur le sujet.
À noter qu’il y a une différence fondamentale entre un serveur MQTT et un forum. Tout l’historique est préservé lorsque le serveur forum redémarre, ce qui n’est pas le cas d’un serveur MQTT (sauf pour une implémentation particulière). Dans le cas d’un serveur MQTT, tous les sujets disparaissent, toutes les souscriptions sont abandonnées.
Un serveur MQTT est avant tout un aiguilleur de messages. Il permet de gérer des comptes utilisateurs, des droits d’accès, etc.
Comme le laisse entendre l’illustration en début de chapitre, un serveur MQTT peut être localisé sur un serveur dans le Cloud tout comme il peut être installé sur un serveur du réseau d’entreprise ou un nano ordinateur tel que le Raspberry Pi sur un réseau domestique.
Comme sur un forum, les informations sont organisées en sujets (dits topics en anglais), aussi appelés fils de discussion. Plusieurs intervenants peuvent interagir sur un sujet (en y envoyant des messages) et plusieurs intervenants peuvent suivre le déroulement des conversations. Il est commun sur les forums de pouvoir s’abonner à un sujet et d’être notifié de toute nouvelle information publiée sur ledit sujet (fonctionnement typique de MQTT).
Sur les forums, les discussions sont organisées en catégories, voire sous-catégories et en sujets. L’organisation est similaire en MQTT.
Sur un broker MQTT, tout le monde peut créer un topic, publier des messages sur un topic et consulter les publications sur les différents topics disponibles.
Voici quelques exemples de topics :
projet/air-qualite/paris/mod-c724fd5680a7/particules
taxi/flotte/eda024/vitesse
taxi/flotte/eda024/position
taxi/flotte/eda024/direction
Maison/Cave/Humidite
Derrière le terme publishers (de to publish signifiant « publier ») se cachent les systèmes matériels publiant des informations vers le broker MQTT.
Sur un forum, le publisher serait un utilisateur envoyant un message dans un fil de discussion. L’utilisateur à la possibilité d’écrire plusieurs messages, dans plusieurs sujets différents.
Les publishers MQTT sont principalement des senseurs connectés ou des processus de surveillance envoyant des données télémétriques.
Le publisher envoie un message sur un topic particulier. Bien que le message soit une chaîne de caractères, le publisher peut convertir une valeur entière ou une valeur en virgule flottante en chaîne de caractères (représentation textuelle) avant son envoi dans le message MQTT.
Un publisher n’est pas limité à un seul topic, il peut envoyer plusieurs messages distincts sur différents topics.
Sauf application de règles de sécurité particulières, les topics sont automatiquement créés sur le broker MQTT si ceux-ci n’existent pas encore au moment de la réception du message.
Sauf application de règles de sécurité particulières, le publisher peut envoyer un message sur le topic de son choix.
Attention aux paramètres régionaux
Bien que le protocole MQTT ne s’embarrasse pas du type des données lors de la transmission de valeurs sur un topic (valeur sans typage, MQTT ne transmet que des chaînes de caractères), la transmission dans le message de données télémétriques requiert une conversion de valeur numérique/décimale en chaîne de caractères.
Éviter les fonctions de conversion faisant intervenir les paramètres régionaux, car cela permet d’éviter les problèmes d’interopérabilité. Par exemple, la conversion d’un nombre en virgule flottante doit idéalement utiliser un point (et non une virgule) comme séparateur décimal et aucun séparateur de milliers (qui est souvent un espace ou une virgule). Cette remarque concerne surtout les applications sur PC et les systèmes embarqués propulsés par un système d’exploitation, les microcontrôleurs ne prenant habituellement pas en charge la gestion des paramètres régionaux.
Le RFC7159 - « The JavaScript Object Notation (JSON) Data Interchange Format » de l’IETF (Internet Engenering Task Force) est une excellente référence et source d’information pour la conversion de données typées. Voir https://tools.ietf.org/html/rfc7159.
Les subscribers sont les clients du broker. Un subscriber souscrit à un (ou plusieurs) topic afin d’être notifié de l’arrivée de nouveaux messages sur le (les) dit(s) topic(s).
Sur un forum, cela correspondrait à une demande de notification (souvent par e-mail) sur un sujet en particulier. L’utilisateur reçoit une nouvelle notification à chaque parution d’un message sur le sujet en question.
Sur le broker MQTT, le mécanisme de souscription utilise une expression de filtrage afin de sélectionner le ou les topics concernés. L’expression de filtrage est très similaire à la constitution d’un topic (et d’une hiérarchie de topics). L’expression de filtrage inclut l’utilisation de caractères jokers permettant la sélection de plusieurs topics en une seule opération.
Une fois l’expression de filtrage utilisée, le subscriber recevra une copie de tous les nouveaux messages reçus par le broker MQTT et répondant au filtre.
Un subscriber peut émettre plusieurs souscriptions.
Un subscriber peut également agir comme publisher.
Sauf règle de sécurité spécifique au broker MQTT utilisé, un subscriber peut souscrire à n’importe quel topic.
Le ClientId est l’acronyme de Client Identifier (identification client). Le ClientId permet d’identifier chacun des clients se connectant sur le broker MQTT, il est par conséquent unique sur celui-ci.
Cette information peut être omise lors d’une utilisation rudimentaire du broker MQTT. Chaque objet/client étant alors considéré comme anonyme, bien que le broker génère un ClientId renvoyé en réponse à la demande de connexion.
Le ClientId revêt par contre d’une tout autre importance lorsqu’il faut maintenir un état du client (ex. : solution professionnelle et stockage en base de données) ou lorsqu’il s’agit d’exploiter un « client persistant » (fonctionnalité standard de MQTT abordée plus loin). Il est alors nécessaire de fournir un ClientId au moment de la connexion sur le broker MQTT. Cela permet à chaque client de s’identifier de façon univoque.
Exemples de ClientId :
•.ClientId-i5X4J8DAhc
•.eabca639f02a472d103bb9f17a685778db997971
•.cabane
La mise en œuvre d’un client persistant (cf. Les clients persistants de ce chapitre) nécessite l’utilisation d’un ClientId communiqué par l’objet.
Dans MQTT, les messages contenant les informations sont publiés sur des topics. Les topics (terme anglais signifiant « sujets ») représentent des éléments clés de la communication MQTT. Ils sont organisés sous forme hiérarchique en utilisant une notation proche des chemins d’accès aux fichiers.
Le topic contient un chemin d’accès à l’information et un élément d’identification de la donnée concernée (ex. : Humidite, Statut, Lux). Un peu comme le nom d’un fichier dans un répertoire.
Sur un disque dur, le topic correspond donc au chemin d’accès combiné au nom de fichier qui permet d’accéder à l’information contenue dans ledit fichier. Sur MQTT le topic permet de localiser le message et d’accéder à l’information que celui-ci contient.
Hormis des contraintes de sécurité éventuellement configurées sur le serveur MQTT, l’organisation des topics est à la libre convenance du développeur.
Les topics sont également utilisés par le broker MQTT pour identifier les clients qui doivent être notifiés d’un nouveau message. L’élaboration de la hiérarchie des topics doit être réalisée avec soin et est à respecter à la lettre lors du développement des différents éléments du projet.
Voici quelques exemples de topics identifiant des informations publiées sur un broker MQTT (ne reprend pas les valeurs publiées).
Maison/Rez/Cuisine/Temp
Maison//Rez/Cuisine/Humidite
Maison/Rez/Cuisine/Lux
Maison/Rez/Salon/Temp
Maison/Rez/Salon/Lux
Maison/Portail/Statut
Maison/Jardin/Cabane/Temp
Maison/Jardin/Cabane/Humidite
Maison/Jardin/Cabane/Pression
Maison/Jardin/Temp
Maison/Jardin/Pression
Maison/Jardin/Lux
Par exemple, le topic Maison/Rez/Cuisine/Temp permet de clairement identifier un relevé de température (Temp) dans la cuisine. La mention de Rez dans la hiérarchie du topic n’est superflue qu’en apparence. Comme cela sera démontré plus loin, il est possible d’être notifié pour toutes les températures du Rez.
Les exemples ci-dessus mettent en évidence d’autres relevés de températures. Ainsi que d’autres types de relevés comme l’humidité relative (Humidite) ou la luminosité avec précision (Lux).
Les exemples permettent de constater que :
•.Les différents niveaux hiérarchiques du topic sont séparés par un « / ».
•.Le nom du relevé télémétrique (la mesure) est le dernier élément du topic.
•.Le topic peut être composé en toute liberté.
•.Le nom du topic n’indique aucune information concernant les unités ou le format des valeurs. Cela relève du domaine des conventions de développement.
Viennent se greffer quelques règles de création :
•.Un topic doit contenir au minimum un caractère pour être valide.
•.Un topic est sensible à la case. Par conséquent, Maison/Rez/Cuisine/Temp n’est pas identique à Maison/Rez/cuisine/temp qui seront considérés comme deux topics distincts.
•.Un topic est une chaîne de caractères UTF-8.
Par essence, le message d’un broker MQTT ne devrait contenir qu’une information télémétrique. À comprendre : un seul relevé, une seule information !
Si l’objet doit renvoyer plusieurs informations (ex. : température, pression atmosphérique d’un unique senseur comme le BMP280 de Bosch), alors ces relevés doivent être publiés sur des topics différents comme le prévoit le standard.
maison/jardin/cabane/temp → 28.2
maison/jardin/cabane/pathm → 1030.7
Cette approche présente l’avantage de ne pas réclamer de traitement complémentaire pour utiliser et exploiter les informations souhaitées. Le message peut donc être traité quelle que soit la puissance de la plateforme (PC ou objet IoT) y ayant souscrit.
Dans le même ordre d’idée, si une unité de mesure correspondant au réglage physique d’un appareil doit être communiquée avec la valeur (par exemple, A pour ampères ou mA pour milliampères), il est recommandé d’utiliser un topic séparé pour communiquer l’unité au broker plutôt que d’inclure cette unité avec la valeur de mesure.
atelier/machinerie/laser/courant → 15.3
atelier/machinerie/laser/courant-unite → A
Une tendance de plus en plus populaire et exploitée par de nombreuses solutions est d’utiliser le standard JSON pour transmettre de multiples informations structurées dans un seul message.
maison/jardin/cabane/bmp280 → {"counter": {"valeur": 123},
"pression": {"valeur": 1030.1, "unite": "millibar"},
"temperature": {"valeur": 15.7, "unite": "celsius"}}
Dans l’exemple ci-dessus, les deux relevés sont envoyés en un seul message, incluant également des informations sur les unités employées ainsi qu’un compteur de relevé.
Bien que séduisante, cette approche non standard est jugée contre-productive. En effet, elle alourdit les messages et nécessite un traitement spécifique pour extraire les informations. Possibilité de traitement qui n’est pas forcément à la portée de tous les subscribers.
De même, le stockage des informations dans une base de données requiert un traitement plus lourd dans le cas d’un message structuré (JSON) que dans le cas d’un message simple contenant le relevé.
L’utilisation de messages encodés ou structurés (type JSON, XML ou autre) doit faire l’objet d’une étude attentive avant sa mise en application.
Bien que non standard, cette approche peut se montrer fort pratique comme le démontre l’implémentation du tableau de bord Crouton (http://crouton.mybluemix.net/crouton/gettingStarted).
Crouton est un projet Node.js permettant de visualiser et contrôler des objets IoT à partir d’une solution qui requiert un minimum de configuration. Crouton s’appuie sur un broker MQTT et des messages structurés au format JSON (cf. GitHub de crouton sur https://github.com/edfungus/Crouton).
Les points ci-dessous reprennent quelques recommandations utiles pour l’élaboration d’un topic et du contenu des messages :
•.Ne pas utiliser de « / » en début de topic. Cela crée un premier niveau vide (de 0 caractère) sans pour autant apporter une fonctionnalité supplémentaire.
•.Éviter les espaces, préférer les tirets. Les espaces sont autorisés, mais peuvent réserver de nombreuses surprises durant le traitement de l’information par les logiciels utilisateurs. L’information sera toujours plus lisible avec un tiret qu’avec un espace, que cela soit durant une phase de débogage ou un traitement SQL (lors de l’insertion des informations en base de données).
•.Éviter les niveaux de topic vides. Bien qu’il soit autorisé d’utiliser un niveau de topic vide (ex. : Maison///Temp), cette technique est déconseillée, car elle nuit à la maintenance et à l’exploitation de tels topics.
•.Évitez les accents, les caractères spéciaux et les caractères non imprimables. Bien que le topic soit une chaîne de caractères en UTF-8 supportant donc toutes les spécificités alphabétiques, les plateformes microcontrôleurs disposent rarement de ressources suffisantes pour supporter ce standard dans son ensemble. D’une façon générale, restreindre l’élaboration des topics aux sous-ensembles A..Z, a..z, 0..9 avec tiret et point offre déjà un large ensemble de possibilités en évitant les complications inattendues.
•.Créer des topics concis. Les topics font partie du trafic réseau lors de chaque envoi. Ces chaînes de caractères sont également traitées par le broker MQTT et par conséquent chaque octet compte pour réduire le trafic et la charge d’un projet d’envergure. Il est opportun de réduire la longueur des topics là où cela est raisonnablement possible. On préférera temp ou t pour température, pres ou p pour pression ou hrel pour humidité relative, cabane sera préférable à cabane-de-jardin, et ainsi de suite.
•.Anticiper l’extension du projet. Durant l’élaboration des topics, il est préférable de concevoir une hiérarchie ouverte capable d’accueillir de nouvelles fonctionnalités. Par exemple, un réseau de relevés de températures utilisant les topics maison/salon et maison/cuisine est un système fermé ne permettant pas l’ajout de nouvelles mesures (comme la luminosité). A contrario, l’utilisation des topics maison/salon/temp et maison/cuisine/temp est extensible, car il est possible d’ajouter de nouveaux topics issus de nouveaux senseurs. Par exemple, la mesure de la luminosité pourrait utiliser les nouveaux topics maison/salon/lux et maison/cuisine/lux tout en respectant la hiérarchie déjà en place.
•.Intégrer un élément d’identification. À considérer lors de la création d’un projet de grande ampleur, il est peut-être opportun d’inclure un identifiant client (comme le ClientId) dans le topic. Cela permet d’identifier la source correspondant à la donnée. Si cela semble excessif lorsqu’il s’agit du relevé de la température du salon, cela s’avérera très pratique pour un projet distribué auprès de multiples clients. Cela permet également de renforcer des règles de sécurité si cela est supporté par le broker MQTT cible. Une telle configuration permet d’empêcher l’accès illicite sur des topics contenant une identification client ne correspondant pas à celle utilisée pour contacter le broker MQTT (le ClientId ClientA ne pourrait pas accéder aux topics du ClientB).
•.Éviter les topics généralistes. Un topic généraliste est un topic dont le contenu du message serait manipulé de sorte à pouvoir envoyer différents types d’informations. Par exemple, des messages successifs tels que temp=15.6 ou lux=40.5 ou encore statut=ON sur un même topic. Comme précisé dans le point précédent concernant l’envoi de données au format JSON, cette approche est considérée comme contre-productive du point de vue de MQTT. En effet, il n’est pas possible d’appliquer une expression de filtrage, car celle-ci ne couvre pas le contenu des messages (pour n’obtenir que les messages contenant une température). Une telle approche ne suivant pas les recommandations MQTT, l’utilisation de topics généralistes pourrait priver le développeur de fonctionnalités avancées offertes par certains brokers MQTT. Pour finir, le stockage des informations en provenance d’un topic généraliste est moins intéressant vu le traitement supplémentaire que cela implique pour exploiter correctement les différentes données. La solution est simple et évidente : utiliser des topics spécifiques pour chaque cas : maison/salon/temp, maison/salon/lux et maison/salon/statut.
•.Documenter les topics. Dans de nombreux cas, les topics serviront à la transmission de données télémétriques en utilisant des topics explicites et des données exprimées en unités du système international (la température en °C, le courant en ampère, la tension en volts, etc.). En cas de transmissions d’informations moins standardisées (ex. : valeur exprimée en milliampères), il est opportun de le préciser dans une documentation. D’une façon générale, disposer d’une documentation, même succincte, est une excellente approche.
Bien que le protocole MQTT supporte les accents, les espaces et les caractères spéciaux dans les topics, il est préférable d’éviter ces éléments lors de l’élaboration des topics. Ce n’est pas parce qu’une fonctionnalité est disponible qu’il faut forcément l’exploiter. En effet, UTF-8 connaît différents types d’espaces qui ne sont pas forcément égaux entre eux et il est assez commun d’éviter l’utilisation de caractères « étrangers » au cœur d’un développement. Ce qui est vrai pour le télougou (langue d’Inde pratiquée par 75 millions d’habitants) l’est tout autant pour les caractères spécifiques à la langue française.
Les topics système débutent souvent par $SYS, et ceux-ci maintiennent des informations propres au fonctionnement interne du broker MQTT. Il n’y a cependant pas d’harmonisation sur le sujet et, de fait, chaque implémentation de broker MQTT offre une hiérarchie de topics plus ou moins différente pour $SYS.
Les topics débutant par un $ ne sont pas traités comme les autres topics. Ils ne sont pas sélectionnés à l’aide du filtre # (le joker multiniveau, voir ci-dessous).
À titre d’exemple, voici quelques topics $SYS issus de la documentation du broker Eclipse® Mosquitto. Cette information est disponible sur : https://github.com/mqtt/mqtt.github.io/wiki/SYS-Topics
•.$SYS/broker/load/bytes/received : nombre d’octets reçus depuis le démarrage de broker.
•.$SYS/broker/load/bytes/sent : nombre total d’octets envoyés depuis le démarrage du broker.
•.$SYS/broker/clients/connected : nombre de clients actuellement connectés.
•.$SYS/broker/messages/received : nombre total de messages reçus (de tout type) depuis le démarrage du broker.
•.$SYS/broker/messages/sent : nombre total de messages envoyés (de tout type) depuis le démarrage du broker.
•.$SYS/broker/subscriptions/count : nombre total de souscriptions actives sur le broker.
•.$SYS/broker/time : heure sur le serveur.
•.$SYS/broker/uptime : temps d’activité, en secondes, depuis la mise en ligne du broker.
•.$SYS/broker/version : la version du broker.
Lorsqu’un topic système est saisi sur une ligne de commande avec mosquitto_sub, il est nécessaire d’utiliser une séquence d’échappement en précédant le « $ » d’un caractère « \ ». Par exemple : mosquitto_sub -h pythonic.local -t "\$SYS/broker/load/bytes/sent" -v
Les topics ci-dessous seront utilisés durant l’élaboration du projet.
Le premier niveau, maison, permet d’étendre le projet à d’autres lieux (résidence, travail, dépendances). La localisation utilise deux sous-niveaux (ex. : rez/salon) permettant une découpe de l’information en niveau/étage physique. Les mesures extérieures sont volontairement organisées en suivant cette découpe de deux sous-niveaux (ex. : exterieur/cabane ou exterieur/jardin).
Les topics sont élaborés en minuscule et sans accents.
L’approche respectant strictement quatre niveaux permet de collecter toutes les températures de la maison avec une seule souscription (ex. : maison/+/+/temp).
connect/<ClientId>
Le client envoie son adresse MAC (si disponible) lorsqu’il est mis en service. Le <ClientId> permet d’identifier l’objet qui se connecte (ex. : veranda, salon, cabane, etc) sur le broker MQTT.
maison/exterieur/cabane/pathm
Pression atmosphérique en hectopascal (hPa). Relevé depuis la cabane de Jardin. Un relevé toutes les 20 minutes.
maison/exterieur/cabane/temp
Température dans la cabane en degré Celsius (°C). Un relevé par heure.
maison/exterieur/cabane/lux
Relevé de luminosité en Lux depuis la cabane. Un relevé par heure.
maison/exterieur/jardin/hrel
Humidité relative extérieur en pourcentage. Un relevé par heure.
maison/exterieur/jardin/temp
Température extérieure en degré Celsius (°C). Un relevé par heure.
maison/rez/salon/temp
Température du salon en degré Celsius (°C). Un relevé par heure.
maison/rez/salon/pir
Indicateur de mouvement dans le salon.
Envoi de « MOUV » lors d’une première activation. Répétition de « MOUV » toutes les 15 minutes si une activité régulière est détectée durant ladite période de 15 minutes. Envoi de « NONE » (sans répétition) si aucune activité n’est détectée au bout de 15 min.
maison/rez/veranda/temp
Température de la véranda en degré Celsius (°C). Un relevé par heure.
maison/rez/veranda/ldr
La photo-résistance est utilisée comme indicateur de l’état d’éclairage de la pièce. Retourne les valeurs « NOIR » et « ECLAIRAGE ». Envoi de l’information à chaque changement d’état.
maison/rez/veranda/portefen
« OUVERT » ou « FERME » indique respectivementsi la porte-fenêtre est ouverte ou fermée.
Envoi uniquement lors du changement d’état.
maison/rez/veranda/ldr
Utilisation d’une photorésistance pour détecter l’éclairage de la pièce avec les valeurs « NOIR » et « ECLAIRAGE ». Information vraiment pertinente la nuit.
maison/cave/chaufferie/cmd
Ce topic permet d’envoyer des commandes à l’objet contrôlant la chaufferie. L’impact d’une commande est reporté dans le topic maison/cave/chaufferie/etat. L’objet n’accepte qu’une seule commande par intervalle de 10 secondes.
Les commandes supportées sont les suivantes :
•.MARCHE : mise en marche de la chaudière par l’intermédiaire du relais.
•.ARRET : mise à l’arrêt de la chaudière par l’intermédiaire du relais.
maison/cave/chaufferie/etat
Topic sur lequel l’objet communique ses changements d’état. L’objet communique également son état juste après le démarrage.
Les états communiqués sont les suivants :
•.MARCHE : la chaudière est en marche.
•.ARRET : la chaudière est à l’arrêt.
•.REJECT-CMD : la commande communiquée sur maison/cave/chaufferie/cmd a été rejetée. L’état actuel de l’objet est renvoyé sur le topic au bout de quelques secondes.
maison/cave/chaufferie/temp-eau
Indique la température du circuit d’eau de la chaufferie en degrés Celsius (°C). Un relevé par heure. Un relevé toutes les 10 minutes pendant une heure lors de l’activation/désactivation de la chaudière.
•.Le projet ne transporte aucune donnée vitale et envoie régulièrement des informations sur les différents topics.
•.La stabilité de la transmission peut être considérée comme fiable. Le réseau Wi-Fi domestique offre une stabilité et une fiabilité raisonnablement équivalentes à celles d’un réseau Ethernet filaire.
•.Une perte de connexion, l’interruption d’envoi de message (ex. : coupure de courant dans la cabane) n’impacte pas le fonctionnement ou la stabilité globale du projet. Les informations seraient simplement « manquantes ».
Une QoS 2 serait excessive compte tenu des points ci-dessus.
Une QoS 1 pourrait convenir étant donné qu’il s’agit principalement d’un projet de surveillance (monitoring), la duplication de messages ne représente donc pas un problème. QoS 1 assurerait l’acheminement (au moins une fois) des messages. Il serait un choix approprié si le projet devait s’étendre au-delà du réseau local (ex. : utilisation d’un broker MQTT en ligne, relevés en provenance d’autres lieux géographiques...).
QoS 0 sera tout à fait convenable pour ce projet d’exploration où les conditions de communications (réseau Wi-Fi local) sont idéales.
Plateforme IoT et QoS max
La plateforme IoT sélectionnée a également un impact sur la QoS maximum du projet.
Le présent projet utilise des ESP8266 sous MicroPython. La documentation MQTT de MicroPython révèle que la qualité de service maximum supportée est 1.
Il n’est donc pas possible d’opter pour une QoS 2 étant donné qu’elle sera systématiquement rétrogradée à 1 à cause des ESP8266.
Bien que l’installation ne sorte pas du cadre du réseau local domestique, il y a plusieurs points de sécurisation à considérer.
•.ClientId : non requis dans une installation domestique, cet élément est souvent essentiel dès lors que vous utilisez une solution professionnelle. Étant donné l’envergure modeste de ce projet, le ClientId contiendra une identification rudimentaire de l’objet (ex. : « cabane »).
•.Login : MQTT accepte un couple login/mot de passe pour protéger l’accès au broker MQTT. Cela évite qu’un intrus ne se branche sur le broker pour y collecter des informations ou injecter des données corrompues.
•.Encryption : idéalement, les données circulant entre les différents intervenants devraient être cryptées. MQTT s’appuie sur le protocole TLS/SLL pour assurer l’encryptage au niveau Ethernet (via la couche de transport).
La mise en place de l’encryptage SSL/TSL sur le broker MQTT n’est pas une tâche triviale et dépend également du support de SSL/TSL sur les ESP8266. La consultation de forums indique le support logiciel SSL/TSL pour ESP8266 sous MicroPython (sans authentification). Sa mise en œuvre aussi bien côté Raspberry Pi que côté ESP8266 sort du cadre du présent ouvrage.
L’utilisation d’une combinaison login/mot de passe pour accéder au broker MQTT est un minimum raisonnable pour un apprentissage sur le réseau domestique d’autant qu’il est possible de s’appuyer sur le cryptage du réseau Wi-Fi (même si celle-ci n’est pas infaillible). Le renforcement de la sécurité en utilisant TSL/SSL est un plus nécessaire dès lors que le projet transporte des informations sensibles.
Les étapes suivantes permettent de configurer le broker MQTT de façon à n’accepter que les connexions authentifiées avec le login « pusr103 » et le mot de passe « 21052017 ». Ces informations sont requises pour toute publication ou souscription sur le broker MQTT Mosquitto.
La première opération consiste à créer un fichier d’authentification pour y stocker les mots de passe du broker MQTT.
sudo mosquitto_passwd -c /etc/mosquitto/passwd pusr103
La commande mosquitto_passwd permet d’ajouter un utilisateur dans le fichier de mot de passe de Mosquitto. Une fois la commande saisie, l’utilitaire demande le mot passe.
Le drapeau -c permet de créer le fichier passwd. Ce dernier sera écrasé s’il existe déjà.
Par la suite, il est possible de lister les utilisateurs en affichant le contenu du fichier à l’aide de la commande more /etc/mosquitto/passwd.
Création d’un utilisateur sur le broker MQTT
Ensuite, le fichier mosquitto.conf est modifié à l’aide de l’utilitaire nano. La modification du fichier de configuration vise à rejeter les connexions anonymes et à utiliser le fichier de mot de passe nouvellement créé.
Saisir la commande suivante :
sudo nano /etc/mosquitto/mosquitto.conf
Et ajouter les lignes suivantes dans le fichier de configuration :
allow_anonymous false
password_file /etc/mosquitto/passwd
Comme dans la capture ci-dessous :
Modification du fichier mosquitto.conf
Les modifications sont enregistrées en utilisant la combinaison de touches [Ctrl] O pour sauver le fichier, suivie du retour clavier pour confirmer le nom du fichier. Pour finir, presser [Ctrl] X pour quitter le programme nano.
Pour finir, redémarrer le broker MQTT afin qu’il utilise la nouvelle configuration.
sudo systemctl stop mosquitto.service
sudo systemctl start mosquitto.service
À partir de maintenant, toutes les opérations de publication ou de souscription nécessiteront l’utilisation d’un login et du mot de passe correspondant.
Tester la publication
La commande suivante produit une erreur, car elle ne contient aucune authentification :
mosquitto_pub -h pythonic.local -t "eni-editions/pythonic" -m "message de test"
Connexion non autorisée sur le broker Mosquitto
En ajoutant les paramètres -u pour l’identification utilisateur et -P pour le mot de passe, la commande de publication est autorisée sur le broker.
mosquitto_pub -h pythonic.local -t "eni-editions/pythonic" -m
"message de test" -u pusr103 -P 21052017
Tester la souscription
De même, la souscription avec l’utilitaire mosquitto_sub accepte les mêmes paramètres -u et -P pour authentifier l’utilisateur.
L’exemple ci-dessous reprend la souscription (dans TERM1) et la publication (dans TERM2) sur le broker en utilisant l’authentification définie.
Publication et souscription avec authentification utilisateur
La bibliothèque Python a été installée sur le Raspberry Pi en même temps que le broker MQTT Mosquitto. La bibliothèque paho-mqtt, anciennement nommée python-mosquitto, permet d’interagir avec le broker Mosquitto. Le projet Eclipse Paho vise à créer des implémentations open source du protocole MQTT pour différents langages de programmation (C, Python, Arduino, Java, JavaScript, C#, etc.).
À défaut d’une installation MQTT complète, il est possible d’installer uniquement la bibliothèque MQTT Python pour Python 2.7 (encore très utilisée sur Raspberry Pi).
sudo pip install paho-mqtt
Le script de test test-mqtt-client-sub.py indique comment utiliser la bibliothèque MQTT pour réaliser une souscription sur le broker.
Le script test-mqtt-client-sub.py est disponible sur le référentiel GitHub suivant : https://github.com/mchobby/la-maison-pythonic/tree/master/python/divers
Seul le fichier test-mqtt-client-sub.py doit être transféré sur le Raspberry Pi pour tester la souscription.
01: # coding: utf-8
02: """ Souscription au topic "demo/#" sur le broker Eclipse Mosquitto.
03:
04: Utilise une authentification login/mot-de-passe sur le broker
05: """
06: import paho.mqtt.client as mqtt_client
07:
08: # Configuration
09: MQTT_BROKER = "pythonic.local"
10: MQTT_PORT = 1883
11: KEEP_ALIVE = 45 # intervale en seconde
12:
13: def on_log( client, userdata, level, buf ):
14: print( "log: ",buf)
15:
16: def on_connect( client, userdata, flags, rc ):
17: print( "Connexion: code retour = %d" % rc )
18: print( "Connexion: Statut = %s" %
19: ("OK" if rc==0 else "échec") )
20:
21: def on_message( client, userdata, message ):
22: print( "Reception message MQTT..." )
23: print( "Topic : %s" % message.topic )
24: print( "Data : %s" % message.payload )
25:
26:
27: client = mqtt_client.Client( client_id="client007" )
28:
29: # Assignation des fonctions de rappel
30: client.on_message = on_message
31: client.on_connect = on_connect
32: #client.on_log = on_log
33:
34: # Connexion au broker MQTT
35: client.username_pw_set( username="pusr103",
36: password="21052017" )
37: client.connect( host=MQTT_BROKER, port=MQTT_PORT,
38: keepalive=KEEP_ALIVE )
39: client.subscribe( "demo/#" )
40:
41: # traitement des messages
42: client.loop_forever()
•.Ligne 6 : chargement de la bibliothèque mqtt.
•.Ligne 8-11 : définition des constantes identifiant le point de contact sur le broker.
•.Ligne 13 : fonction de rappel affichant des messages de log. Très pratique pour le débogage.
•.Ligne 16-19 : fonction de rappel confirmant le statut de la connexion sur le broker MQTT.
•.Ligne 19 : test ternaire qui affiche « OK » si la connexion est réalisée avec succès (rc==0). Sinon rc contient un code d’erreur résultant de l’échec de la connexion, auquel cas c’est « échec » qui est retourné.
•.Ligne 21-24 : fonction de rappel appelée lorsqu’un message est réceptionné suite à une des souscriptions sur le broker. La fonction affiche le topic et le contenu du message.
•.Ligne 27 : création d’une instance de la classe Client (variable client). Bien qu’optionnel, le ClientId est mentionné à titre informatif.
•.Ligne 30-31 : assignation de la fonction de rappel pour on_message, on_connect sur le client.
•.Ligne 32 : bien que mise en commentaire, l’assignation de la fonction de rappel on_log permet d’obtenir de nombreuses informations sur les différents échanges entre le client MQTT et le broker MQTT.
•.Ligne 35 : utilisation de la fonction username_pw_set pour indiquer le login et le mot de passe pour contacter le broker.
•.Ligne 37 : connexion sur le broker.
•.Ligne 39 : souscription au topic « demo » et tous ses sous-topics.
•.Ligne 42 : exécute la boucle de traitement des messages du client MQTT et effectue les différents appels aux fonctions de rappels. Cette boucle est exécutée indéfiniment. Presser la combinaison de touches [Ctrl] C pour interrompre le fonctionnement du script.
Il est important de signaler que l’appel de la méthode connect() à la ligne 37 n’est pas bloquant ! Si le client ne peut pas se connecter sur le broker (ex. : mauvais login/mot de passe ou connexion instable), le script passera à l’exécution de la ligne 39. Le client fera cependant différentes tentatives en tâche de fond, ce que démontre l’utilisation de la fonction de rappel on_log().
Ce script peut être testé en le démarrant avec la commande python test-mqtt-client-sub.py (TERM1), puis en envoyant un message sur le topic souscrit à l’aide de la commande (TERM2).
mosquitto_pub -h pythonic.local -t "demo/msg" -m "abc" -u pusr103 -P 21052017
La commande mosquitto_pub dans TERM2 déclenche une publication sur le topic demo/msg, message qui apparaît immédiatement dans TERM1 exécutant le script Python ayant une souscription sur demo/#.
Ce qui produit le résultat suivant :
Test de souscription MQTT en Python.
Utilisation du log
La fonction de rappel on_log() peut s’avérer très utile pour suivre l’évolution des échanges MQTT dans le script.
L’exemple suivant se propose de démontrer l’utilité des logs en modifiant le script test-mqtt-client-sub.py comme suit :
1. | Retirer la mise en commentaire (le caractère « # ») de la ligne 32 pour obtenir client.on_log = on_log. |
2. | Modifier le mot de passe à la ligne 35 pour provoquer une erreur de login sur le broker MQTT. Par exemple, utiliser le mot de passe « erreur » à la place de « 21052017 ». La ligne 35 devrait donc ressembler à ceci : client.username_pw_set( username="pusr103", password="erreur" ) |
Redémarrer le script à l’aide de la commande python test-mqtt-client-sub.py pour voir les différents messages de log s’afficher sur le terminal. Ces messages (sur TERM1) montrent les différentes tentatives de connexion au broker MQTT.
TERM1: python test-mqtt-client-sub.py
(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)
(’log: ’, "Sending SUBSCRIBE (d0) [(’demo/#’, 0)]")
(’log: ’, ’Received CONNACK (0, 4)’)
Connexion: code retour = 4
Connexion: Statut = échec
(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)
(’log: ’, ’Received CONNACK (0, 4)’)
Connexion: code retour = 4
Connexion: Statut = échec
(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)
(’log: ’, ’Received CONNACK (0, 4)’)
Connexion: code retour = 4
Connexion: Statut = échec
Un second essai, en corrigeant le mot de passe, montre les différents messages échangés avec le broker. Y compris la réception d’un message suite à la souscription demo/#.
TERM1: python test-mqtt-client-sub.py
(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)
(’log: ’, "Sending SUBSCRIBE (d0) [(’demo/#’, 0)]")
(’log: ’, ’Received CONNACK (0, 0)’)
Connexion: code retour = 0
Connexion: Statut = OK
(’log: ’, ’Received SUBACK’)
(’log: ’, ’Sending PINGREQ’)
(’log: ’, ’Received PINGRESP’)
(’log: ’, "Received PUBLISH (d0, q0, r0, m0), ’demo/msg’, ... (3 bytes)")
Reception message MQTT...
Topic : demo/msg
Data : abc
Il est possible d’y voir la réception d’un message (PUBLISH) débouchant sur l’appel de on_message(). Les échanges PINGREQ/PINGRESP utilisés pour maintenir la connexion entre le client MQTT et le broker sont également visibles.
Le script de test test-mqtt-client-pub.py effectue des publications sur le broker MQTT.
Le script test-mqtt-client-pub.py est disponible sur le référentiel GitHub suivant : https://github.com/mchobby/la-maison-pythonic/tree/master/python/divers
Seul le fichier test-mqtt-client-pub.py doit être transféré sur le Raspberry Pi pour tester la publication.
01: # coding: utf-8
02: """ Publication de messages sur le topic "demo/machin-chose"
03: du broker Eclipse Mosquitto.
04: Utilise une authentification login/mot-de-passe sur
le broker
05: """
06: import paho.mqtt.client as mqtt_client
07: from time import sleep
08:
09: # Configuration
10: MQTT_BROKER = "pythonic.local"
11: MQTT_PORT = 1883
12: KEEP_ALIVE = 45 # interval en seconde
13:
14: def on_log( client, userdata, level, buf ):
15: print( "log: ",buf)
16:
17: client = mqtt_client.Client( client_id="client007" )
18:
19: # Assignation des fonctions de rappel
20: #client.on_log = on_log
21:
22: # Connexion broker
23: client.username_pw_set( username="pusr103",
password="21052017" )
24: client.connect( host=MQTT_BROKER, port=MQTT_PORT,
keepalive=KEEP_ALIVE )
25:
26: # envoi des messages
27: for i in range(4):
28: print( "Publication iteration %s" % i )
29: r = client.publish( "demo/machin-chose", "message %s"%i )
30: print( " envoyé" if r[0] == 0 else " echec" )
31: sleep( 1 )
•.Ligne 6 : chargement de la bibliothèque mqtt.
•.Ligne 10-12 : définition des constantes identifiant le point de contact sur le broker.
•.Ligne 14 : fonction de rappel affichant des messages de log. Très pratique pour le débogage.
•.Ligne 17 : création d’une instance de l’objet Client (variable client). Bien qu’optionnel, le ClientId est mentionné à titre informatif.
•.Ligne 23 : utilisation de la fonction username_pw_set pour indiquer les informations de login et mot de passe pour contacter le broker.
•.Ligne 24 : connexion sur le broker.
•.Ligne 29 : publication d’un message sur le topic demo/machin-chose. Le résultat de la fonction est récupéré dans la variable r (un tuple de 2 valeurs).
•.Ligne 30 : test ternaire qui affiche « envoyé » si le message est publié. Sinon c’est la valeur « echec » qui est retournée.
•.Ligne 31 : pause d’une seconde. Cette instruction est très importante, car l’envoi de messages en rafale (avec QoS=0) n’est pas correctement acheminé jusqu’au subscriber.
Le script peut être testé en saisissant la commande suivante dans un terminal (TERM2).
mosquitto_sub -h pythonic.local -t "demo/#" -u pusr103 -P 21052017 -v
Puis, en démarrant le script Python à l’aide de la commande python test-mqtt-client-pub.py (TERM1).
Ce qui produit le résultat suivant :
Test de publication en Python sur un broker MQTT
La bibliothèque MQTT client du projet Eclipse Paho utilisée dans ces exemples est documentée sur le serveur GitHub paho.mqtt.python. Ce serveur reprend une description de l’API et des exemples en Python.
L’utilisation du broker MQTT sous MicroPython nécessite la mise en œuvre d’une plateforme MicroPython. Ce point est abordé dans le chapitre relatif à l’ESP8266 (cf. ESP8266 sous MicroPython - MQTT sous ESP8266).
L’ESP8266 est un SoC (System on Chip, Système sur une puce) d’Espressif Systems qui intègre un processeur 32 bits de Tensilica Xtensa LX106 cadencé à 80 MHz, une mémoire RAM de 96 ko, une interface radio 2,4 GHz et une pile TCP/IP complète.
La puce ESP8266
Le Xtensa LX106 dispose de deux cœurs. L’un est dévolu à la gestion de l’interface radio (le Wi-Fi) tandis que le second reste disponible pour les applications utilisateurs. L’ESP8266 peut ainsi proposer à l’application utilisateur une unité de contrôle indépendante avec GPIO (broches d’entrée/sortie), bus série, I2C, SPI sans que celle-ci ne perturbe le traitement des transmissions radio (et inversement). Grâce à son très faible coût, l’ESP8266 permet de réaliser des plateformes Wi-Fi IEEE 802.11 b/g/n autonomes avec seulement quelques composants supplémentaires. L’ESP8266 s’est rapidement trouvé au cœur de nombreuses plateformes et projets permettant de développer des objets connectés.
L’ESP8266 n’étant pas facile à manipuler en l’état, il est également vendu sous forme de modules. Il existe une vingtaine de modules ESP8266 différents, dont l’ESP-12 et ses descendants directs.
Module ESP8266 ESP-12
L’ESP-12 est un module ESP8266 avec antenne intégrée disposant d’une déclaration de conformité FCC et CE. Ces certifications garantissent que les interférences électromagnétiques du module ESP-12 restent en dessous des limites imposées par la FCC (Federal Communication Commission) et la Communauté Européenne.
L’ESP8266 dispose de ressources limitées classant ce dernier dans le domaine des microcontrôleurs. Il est donc possible d’y brancher des senseurs pour collecter des données et d’utiliser les broches d’entrée/sortie pour agir sur l’environnement immédiat (contrôler une pompe, allumer l’éclairage, activer un relais, etc.).
Comparé au célèbre Arduino Uno (le microcontrôleur de référence dans le monde des makers), les ressources d’un ESP8266 sont néanmoins bien supérieures. À titre de comparaison : la mémoire flash peut atteindre 4 Mo (128 fois plus qu’un UNO), la SRAM disponible est de 64 ko (32 fois plus qu’un UNO), la vitesse d’horloge est de 80 MHz (5 fois plus rapide qu’un UNO). C’est sans compter sur le support Wi-Fi offert par l’ESP8266.
Le support Wi-Fi de l’ESP8266 offre des fonctionnalités et des perspectives intéressantes :
•.Client HTTP simple : il permet à l’ESP8266 de collecter (ou envoyer) des informations vers (depuis) un site web ou un service web. Ex. : extraction de la température locale depuis un site de météorologie.
•.Serveur HTTP simple : il permet à l’ESP8266 de fournir des informations sur demande ou d’agir sur demande. Ex. : commande d’un relais depuis une API REST exposée sur l’ESP8266.
•.Serveur Telnet simple : il permet d’établir une session Telnet (communication texte avec un serveur distant) sur l’ESP8266 et utiliser des commandes simples pour contrôler l’application à distance.
•.Client MQTT : il permet de publier facilement des messages sur un broker MQTT (ou de réaliser une souscription pour être informé des nouveaux messages).
•.Mise à jour à distance : en fonction du firmware (micrologiciel embarqué sur une plateforme matérielle) utilisé, il est possible de modifier le programme (script) fonctionnant sur l’ESP8266 via la connexion Wi-Fi.
Il existe bon nombre de plateformes de développement ESP8266. La liste ci-dessous reprend les plateformes les plus populaires :
•.le Huzzah ESP8266 d’Adafruit Industries
•.le Feather Huzzah ESP8266 d’Adafruit Industries
•.le Node MCU Dev Kit de NodeMCU
•.le Wemos D1 de Wemos
Toutes ces plateformes utilisent le module ESP-12 et bénéficient donc des certifications FCC et CE.
Le Feather Huzzah ESP8266 d’Adafruit Industries est une carte de la famille Feather. Feather est une gamme de cartes de développement standardisées, compactes et légères spécialement conçues pour les projets microcontrôleurs embarqués. Le Feather Huzza ESP8266 dispose de fonctionnalités intéressantes qui facilitent le prototypage et le développement de projets ESP8266 (cf. Présentation de l’ESP8266 - Feather Huzzah ESP8266 en détail dans ce chapitre).
Feather Huzzah ESP8266
Le Feather Huzzah ESP8266 étant l’une des plateformes de référence utilisée durant le développement de MicroPython sur ESP8266, elle sera également la plateforme utilisée pour la création des objets de cet ouvrage.
Le Huzzah ESP8266 d’Adafruit Industries est une carte breakout pour ESP8266. Cette dernière propose un breakout des signaux de l’ESP8266 et le minimum de composants nécessaires pour utiliser un ESP8266. La carte dispose d’empattement 2,54 mm compatible avec les plaques de prototypage sans soudure, d’un régulateur de tension 3,3 V et des boutons « Reset » et « GPIO0 » requis pour l’activation du bootloader. Le port série est accessible sur le connecteur FTDI visible sur la droite de la carte. Ce connecteur permet de brancher un convertisseur USB-Série FTDI ou un câble console autorisant ainsi l’envoi d’un nouveau firmware depuis un ordinateur.
Huzzah ESP8266 et convertisseur USB-Série FTDI
Le NodeMCU est également une carte ESP8266 existant sous de nombreuses variantes (facteur de forme différent, avec module ESP-12 certifié ou non certifié, avec différents convertisseurs USB-Série). Ces cartes de développement sont principalement utilisées avec le firmware par défaut NodeMCU qui permet de programmer le module avec des scripts Lua. Comme le Huzzah ESP8266, la carte dispose d’un convertisseur USB-Série, d’un régulateur de tension et des boutons « Reset » et « Flash » (GPIO0) requis pour l’activation du bootloader.
Une des nombreuses variantes de NodeMCU
Le Wemos D1 Mini est une autre carte ESP8266 très populaire (surtout dans l’hexagone) disposant de 4 Mo de mémoire Flash. Tout comme le NodeMCU, Wemos existe sous différentes variantes, avec module ESP8266 certifié ou sans module certifié. Malgré les nombreux fabricants de Wemos, le facteur de forme des Wemos reste identique, ce qui est un avantage certain étant donné qu’il existe des cartes d’extensions pour Wemos.
Wemos D1 mini (à base d’ESP-8266EX, existe également avec des modules ESP-12 certifiés)
Le Wemos D1 mini PRO est une variante intéressante du Wemos. Celui-ci offre une antenne céramique et un connecteur µFl pour brancher une antenne externe. L’autre intérêt du Wemos D1 mini PRO c’est qu’il est équipé de 16 Mo, un espace très utile pour stocker des ressources. La version PRO telle que présentée ici doit faire l’objet d’un processus de certification.
Wemos D1 mini PRO (à base d’ESP-8266EX)
Plusieurs approches sont possibles pour programmer un ESP8266. Les méthodes de programmation les plus utilisées sont :
•.Les scripts Lua. Possible lorsque l’ESP8266 utilise le firmware NodeMCU (firmware par défaut).
•.Les scripts Python. Possible lorsque l’ESP8266 utilise le firmware MicroPython (approche utilisée dans cet ouvrage).
•.Les scripts JavaScript. Possible lorsque l’ESP8266 utilise le firmware Espruino.
•.Les croquis Arduino (C simplifié) téléversés depuis l’environnement Arduino IDE.
•.Du code en C à l’aide du kit de développement d’Espressif Systems.
Les firmwares NodeMCU, MicroPython et Espruino interprètent le contenu de scripts directement sur l’ESP8266. Cette approche présente une certaine souplesse, car elle permet d’adapter et de corriger facilement les scripts avec un éditeur de texte. Ces scripts sont ensuite copiés sur l’ESP8266 où ils seront interprétés. Cette souplesse a cependant un coût, l’exécution d’un script est plus lente qu’un programme natif. NodeMCU est le firmware par défaut des modules ESP-12 qui permet d’interpréter les scripts écrits en Lua. L’utilisation d’un autre langage de script tel que MicroPython nécessite le téléversement d’un nouveau firmware sur le module.
À l’opposé des scripts, Arduino IDE permet de produire des programmes natifs pour ESP8266. Arduino IDE utilise un compilateur pour produire un fichier binaire qui sera ensuite téléversé et exécuté par l’ESP8266. Les programmes natifs seront plus rapides que leurs scripts équivalents. Cela implique cependant une phase de compilation qui alourdit le cycle de développement et nécessite l’utilisation d’un matériel informatique adapté (plus puissant).
Dans le cadre de cet ouvrage, les modules ESP8266 seront programmés avec des scripts Python. Les modules seront mis à jour pour recevoir le firmware MicroPython pour ESP8266.
Parmi les plateformes ESP8266 disponibles sur le marché, le Feather Huzzah ESP8266 d’Adafruit Industries est une plateforme de développement disposant de nombreuses qualités, à prix abordable et disponible dans le monde entier.
Le Feather Huzza ESP8266 embarque le module ESP8266 ESP-12 certifié FCC et CE.
Feather Huzzah ESP8266 (vue de face et vue arrière)
Feather est une gamme de cartes de développement standardisées, compactes et légères spécialement conçues pour les projets microcontrôleurs embarqués. Cette gamme propose des cartes de développement pour plusieurs types de microcontrôleurs ainsi que de nombreuses cartes d’extension. N’hésitez pas à consulter la gamme Feather chez MC Hobby (https://shop.mchobby.be/87-feather) et chez Adafruit (https://www.adafruit.com/category/817) pour de plus amples informations.
La carte Feather Huzzah ESP8266 dispose d’une interface USB-Série (plus exactement USB vers UART) permettant à un ordinateur de communiquer directement avec le port série de l’ESP8266 par l’intermédiaire d’un port USB. L’interface USB-Série du Feather dispose d’une électronique d’auto-réinitialisation (auto-reset) de la plateforme qui réinitialise le module ESP8266 lorsqu’un nouveau firmware doit y être téléversé.
La carte dispose également d’un connecteur pour accumulateur Lithium Polymère 3,7 V (optionnel) et du circuit de recharge correspondant. Le circuit d’alimentation du Feather bascule automatique entre les différentes sources d’alimentation de la carte (USB ou Accumulateur LiPo). L’accumulateur est automatiquement rechargé lorsque le Feather est branché sur une source d’alimentation USB.
Détails techniques de la plateforme :
•.ESP8266 cadencé à 80 MHz
•.4 Mo de mémoire Flash
•.Logique 3,3 V
•.Support Wi-Fi 802.11 b/g/n
•.Convertisseur USB-Série CP2104
•.Régulateur 3.3 V (500 mA en pointe)
•.9 broches GPIO (pouvant également être utilisées pour un bus I2C ou bus SPI)
•.1 entrée analogique (1,0 V max)
•.Circuit de recharge pour accumulateur Lithium Polymère (charge à 100 mA avec LED indicatrice de recharge)
•.Bouton de réinitialisation (Reset)
•.Broche « Power Enable » permettant de désactiver l’alimentation de la carte
•.LED rouge sur le GPIO 0 (usage libre)
•.LED bleue sur le GPIO 2 (usage libre, indique également l’activation du bootloader)
•.Dimensions : 51 x 23 mm (hauteur 8 mm)
Le Feather Huzzah ESP8266, comme l’ESP8266, utilise des signaux en logique 3,3 V. Sauf mention contraire, les broches GPIO ne sont pas tolérantes au 5 V. De même, l’entrée analogique ADC ne supporte pas une tension supérieure à 1,0 V.
Alimentation d’un Feather Huzzah ESP8266
La carte Feather peut être alimentée depuis le connecteur micro USB (en 5 V) ou depuis un accumulateur Lithium Polymère ou Lithium Ion optionnel branché sur le connecteur JST (en noir, sur la gauche de la broche BAT). Le circuit d’alimentation du Feather passe automatiquement d’une source d’alimentation à l’autre. L’accumulateur est automatiquement mis en charge (100 mA) lorsqu’une source d’alimentation 5 V est branchée sur le connecteur micro USB. La charge de l’accumulateur est indiquée par la LED CHG.
Le Feather utilise un régulateur à faible chute de tension (Low Drop Out) pour réguler la tension à 3,3 V, tension de fonctionnement de l’ESP8266. Ce régulateur est capable d’offrir 500 mA en courant de pointe. Étant donné que l’ESP8266 peut occasionnellement consommer jusqu’à 250-300 mA, il est recommandé de ne pas ponctionner plus de 250 mA supplémentaires au risque de faire surchauffer le régulateur de tension.
La liste ci-dessous reprend le détail des différentes broches relatives au circuit d’alimentation :
•.GND : masse commune de tous les composants du Feather.
•.3 V : sortie du régulateur de tension 3,3 V, 500 mA en pointe, 250 mA provisionné pour l’ESP8266, le reste disponible pour les projets.
•.USB : connecté sur le +5 V du connecteur micro USB. Permet de récupérer une source d’alimentation de 5 V lorsque que le Feather est branché sur une source d’alimentation via son connecteur micro USB. La réalisation d’un pont diviseur de tension à l’aide de deux résistances permet de produire un signal avertissant le Feather qu’une source d’alimentation 5 V est disponible.
•.BAT : tension positive de l’accumulateur (de 3,2 à 4,2 V) branché sur le connecteur JST. L’utilisation d’un pont diviseur de tension conjointement à l’unique entrée analogique de l’ESP8266 permet de surveiller la tension de l’accumulateur et d’alerter l’utilisateur si celui-ci chute en dessous de 3,7 V (3,2 V étant la tension minimale de mise en sécurité d’un accumulateur LiPo).
•.EN : broche « Enable » du régulateur de tension 3,3 V. Raccorder cette broche à la masse désactivera le régulateur de tension et coupera l’alimentation de la carte Feather. La broche EN n’a aucune influence sur les broches BAT et USB.
Port série du Feather Huzzah ESP8266
Le port série (plus précisément l’UART, Universal Asynchronous Receiver Transmitter) de l’ESP8266 permet à celui-ci de communiquer avec un périphérique externe pour échanger des informations. Le port série est, entre autres, utilisé pour envoyer un nouveau firmware et dialoguer avec le firmware en cours de fonctionnement sur l’ESP8266 (ex. : transfert de fichiers, messages de débogage, interaction en ligne de commande, etc.).
Sur un Feather Huzzah ESP8266, l’UART est connecté sur un convertisseur USB-Série (CP2104 de Silicon Labs), ce qui permet de dialoguer directement avec l’ESP8266 par l’intermédiaire du port micro USB présent sur la carte Feather.
•.TX : broche de sortie de la liaison série du module ESP8266. Logique à 3,3 V.
•.RX : broche d’entrée de la liaison série du module ESP8266. Tolérante au 5 V.
Le convertisseur CP2104 est très bien supporté par les systèmes Linux où l’installation du pilote spécifique n’est généralement pas requise. Pour les autres systèmes d’exploitation, Silicon Labs propose une page téléchargement pour les pilotes des convertisseurs CP210x (https://www.silabs.com/products/development-tools/software/usb-to-uart-bridge-vcp-drivers).
Le module Feather dispose de plusieurs broches GPIO (General Purpose Input Output, Entrée Sortie pour Usage Général) en logique à 3,3 V.
Bien que certaines d’entre elles soient associées aux bus I2C et SPI, il est également possible d’utiliser les broches GPIO #0,2,4,5,12,13,14,15,16 comme de simples entrées/sorties.
Broches GPIO et bus du Feather Huzzah ESP8266
Le courant maximum par broche est de 12 mA.
Comme sur bon nombre de microcontrôleurs, il est possible d’activer une résistance interne de rappel à +3,3 V (communément appelée « résistance pull-up ») sur presque tous les GPIO.
Lorsqu’une broche est utilisée en entrée avec la résistance pull-up activée, la tension de cette broche est ramenée au niveau logique haut (+3,3 V) à moins qu’un signal ne soit appliqué sur celle-ci.
Toutes les broches, à l’exception de GPIO #16, peuvent générer un signal PWM (Pulse Width Modulation, modulation de longueur d’impulsion).
Les broches suivantes disposent également d’une fonctionnalité spéciale :
•.GPIO #0 : cette broche, équipée d’une résistance pull-up interne, est très spéciale pour l’ESP8266. Même si elle peut être utilisée en tant que GPIO, il est important de saisir sa fonction alternative afin d’éviter un véritable casse-tête durant la réalisation de projets.
•.Le GPIO #0 permet de démarrer le bootloader de l’ESP8266 lorsqu’elle est maintenue au niveau bas durant le démarrage du module. Cette broche est contrôlée par le convertisseur USB/Série pour activer le bootloader lors du téléchargement d’un nouveau firmware. Voir aussi les GPIO #2 et GPIO #15 qui interviennent dans la séquence de démarrage.
•.Le GPIO #0 est raccordé sur la cathode (-) de la LED rouge située à côté du connecteur micro USB. Cette LED fonctionne donc en logique inverse (niveau haut = LED éteinte, niveau bas = LED allumée).
•.Le GPIO #0 est utilisable comme GPIO en sortie. Il n’est pas recommandé d’utiliser cette broche comme entrée, car un niveau bas au démarrage de l’ESP8266 activera le bootloader, ce qui empêchera l’ESP8266 de démarrer comme attendu.
•.GPIO #2 : cette broche qui peut être utilisée comme GPIO est également équipée d’une résistance pull-up (externe).
•.Lors de l’activation du bootloader, cette broche maintenue au niveau haut par la résistance pull-up (et GPIO #15 au niveau bas) indique à l’ESP8266 qu’il doit effectuer sa séquence de démarrage depuis la connexion série (UART). Cela permet de téléverser un nouveau firmware sur l’ESP8266 par l’intermédiaire de la connexion série.
•.Le GPIO #2 est également connecté sur la LED bleue (visible près de l’antenne du module ESP8266). Il est possible de contrôler l’état de la LED en contrôlant le GPIO #2.
•.Le GPIO #2 est également utilisé comme sortie TX de débogage durant le téléversement d’un nouveau firmware. La LED sert donc également d’indicateur d’activité durant la phase de téléversement.
•.GPIO #15 : cette broche GPIO est libre d’usage. Elle est équipée d’une résistance pull-down (qui ramène le potentiel à la masse). La configuration des broches GPIO #0, GPIO #2 et GPIO #15 permet d’activer et de configurer le bootloader lors de la séquence de démarrage de l’ESP8266.
•.GPIO #16 : cette broche GPIO est libre d’usage. Elle propose une fonction particulière permettant de sortir le module ESP8266 du mode veille. Cette broche ne peut pas être utilisée pour des fonctionnalités de haut niveau comme la génération d’un signal PWM.
•.GPIO #12, #13, #14 : ces broches correspondent au bus SPI matériel de l’ESP8266. Elles sont dédoublées sur le côté opposé de la carte. Les broches #12, #13, #14 correspondent respectivement aux signaux MISO, MOSI, SCK.
•.GPIO #4, #5 : ces broches peuvent être utilisées pour le bus I2C. Elles correspondent respectivement aux signaux SDA (ligne de donnée) et SCL (ligne d’horloge). L’ESP8266 ne dispose pas d’un bus I2C matériel, celui-ci est donc implémenté par voie logicielle (aussi appelée « Bit Banging I2C »).
•.Le bus I2C est un bus de communication maître-esclave de type série. Grâce au mécanisme d’adressage I²C, il est possible de brancher plusieurs senseurs sur ce bus à 2 fils. Seul le maître (l’ESP8266) peut entamer une communication sur le bus, ce qui permet d’éviter efficacement les collisions de données.
Le bus I2C est un bus de données populaire pour lequel il est facile de trouver des composants et des senseurs déjà pré-assemblés sur des cartes breakouts. À moins d’une absolue nécessité de l’utilisation du GPIO, il est recommandé de garder ces broches libres aussi longtemps que possible. En cas d’absolue nécessité, un composant comme le MCP23017 (I2C GPIO Expander) permet d’ajouter 16 entrées/sorties supplémentaires sans condamner le bus I2C.
Entrée analogique du Feather Huzzah ESP8266
L’ESP8266 dispose d’une entrée analogique accessible via la broche ADC. Le convertisseur analogique/numérique offre une résolution de 10 bits. La lecture de la broche ADC retourne une valeur entre 0 et 1024.
La tension maximale tolérée par le convertisseur analogique/numérique est de 1,0 V. Dépasser cette tension risque de détruire votre ESP8266.
Pour lire une tension supérieure à 1 volt, il est nécessaire de recourir à un pont diviseur de tension (ou similaire) pour ramener celle-ci en dessous de 1,0 V.
•.RST : broche « reset » de réinitialisation de l’ESP8266. L’ESP8266 est réinitialisé lorsque cette broche est placée au niveau bas. La pression du bouton « RST » place la broche « RST » à la masse.
•.CHPD : broche « Chip Down » de l’ESP8266, équivalent de la broche « EN » (Enable) du Feather. Placer la broche CHPD au niveau bas désactive totalement le module ESP8266.
•.Cette broche peut être utilisée pour réduire significativement la consommation du projet en désactivant l’ESP8266 lorsque celui-ci n’est pas nécessaire (la connexion Wi-Fi permanente impliquant une consommation de l’ordre de 80 à 250 mA). Par exemple, un module PIR en logique 3,3 V ayant un temps d’activation d’une trentaine de secondes peut être utilisé avec CHPD pour activer l’ESP8266 et envoyer les notifications d’activation.
Les modules ESP8266 ESP-12 sont chargés par défaut avec le firmware NodeMCU permettant de programmer le module avec des scripts Lua.
Le développement des objets étant réalisé avec Python pour microcontrôleur, cette section explique comment « reflasher » l’ESP8266 pour y téléverser le firmware MicroPython.
Étant donné que le projet utilise également un Raspberry Pi sous Raspbian, nous disposons donc d’une machine Linux pour reflasher les ESP8266. Le Raspberry Pi doit disposer d’une connexion Internet, car il sera nécessaire de télécharger des fichiers binaires et d’installer des paquets logiciels.
L’utilisation d’une machine Linux (comme le Raspberry Pi) présente un réel avantage pour téléverser le firmware MicroPython sur les cartes ESP8266.
Toutes les opérations seront conduites en ligne de commande depuis le Raspberry Pi. Le terminal sera donc notre seul outil de travail, il est accessible :
•.En cliquant sur l’icône Terminal disponible dans la barre des tâches de l’environnement de bureau du Raspberry Pi (Pixel).
•.En configurant le Raspberry Pi (via l’utilitaire raspi-config) pour qu’il démarre en mode console.
•.En utilisant une session SSH (Secure Shell, ligne de commande à distance et sécurisée) depuis votre ordinateur de bureau. C’est l’approche la plus confortable et celle habituellement utilisée par l’auteur.
Le firmware MicroPython à téléverser sur l’ESP8266 est un fichier binaire. Ce dernier est disponible au téléchargement depuis MicroPython.org.
Le firmware MicroPython pour ESP8266 évolue régulièrement, il est donc nécessaire d’identifier la version et le lien de téléchargement de celui-ci.
Une visite à l’adresse http://www.micropython.org/download#esp8266 avec un navigateur Internet indique que le dernier firmware disponible pour ESP8266 est la version 1.9.1. Maintenir la souris au-dessus du lien permet de connaître l’URL à utiliser pour télécharger le firmware (visible en bas à gauche dans le cas de Firefox).
Identification du firmware MicroPython (et lien de téléchargement)
Le lien de téléchargement du firmware au moment de la rédaction de ces lignes est : http://micropython.org/resources/firmware/esp8266-20170612-v1.9.1.bin
L’outil Python esptool doit être installé sur le Raspberry Pi pour pouvoir téléverser le nouveau firmware MicroPython vers l’ESP8266.
Saisissez la commande suivante depuis un terminal :
sudo pip install esptool
Qui affiche le résultat suivant une fois l’utilitaire installé :
Installation de l’utilitaire esptool
Si l’installation d’esptool débouche sur un message d’erreur « TypeError: unsupported operant type(s) for -=: », il est possible de contourner le problème en installant les différents composants séparément :
sudo pip install ecdsa
sudo pip install pyaes
sudo pip install esptool
Branchez l’ESP8266 sur un port USB du Raspberry Pi.
Brancher l’ESP8266 sur le port USB du Raspberry Pi
Puis saisissez la commande dmesg dans le terminal pour identifier le périphérique série associé au convertisseur USB-Série de la carte Feather.
La commande dmesg affiche les messages de débogage du noyau dans lesquels apparaissent les messages concernant la détection d’un nouveau périphérique.
Identification du périphérique série associé à l’ESP8266
Les messages rapportent la détection du convertisseur USB-Série et, sur la dernière ligne, son association avec le périphérique ttyUSB0.
L’ESP8266 est donc accessible via :
/dev/ttyUSB0
La première étape consiste à télécharger le firmware MicroPython sur le Raspberry Pi.
Les quelques lignes suivantes exécutées dans le terminal téléchargent le firmware (identifié ci-avant) sur le Raspberry Pi :
cd /tmp
mkdir upy
cd upy
wget http://micropython.org/resources/firmware/esp8266-20170612-v1.9.1.bin
Ce qui charge le fichier esp8266-20170612-v1.9.1.bin dans le répertoire /tmp/upy/.
Téléchargement du firmware MicroPython
La deuxième étape efface la mémoire flash du module ESP8266 en utilisant la commande suivante :
esptool.py --port /dev/ttyUSB0 erase_flash
Le paramètre --port reprend le chemin d’accès vers le périphérique /dev/ttyUSB0 associé au port série de l’ESP8266.
L’utilitaire esptool affiche différents messages de progression et termine l’opération par une réinitialisation de l’ESP8266 (voir le message « Hard resetting... »).
Effacer la mémoire flash de l’ESP8266
La LED bleue présente sur l’ESP8266 (près de l’antenne), présente une activité en début d’opération. La LED rouge branchée sur le GPIO #0 s’allume faiblement durant (et après) toute l’opération d’effacement.
La fonctionnalité d’auto-réinitialisation du Feather Huzzah ESP8266 nous épargne toute manipulation supplémentaire.
Pour terminer, la commande suivante téléversera le firmware MicroPython sur l’ESP8266. Il s’agit du fichier esp8266-20170612-v1.9.1.bin précédemment téléchargé depuis micropython.org.
esptool.py --port /dev/ttyUSB0 --baud 115200 write_flash
--flash_size=detect -fm dio 0 esp8266-20170612-v1.9.1.bin
Le débit est volontairement réduit à 115200 bauds afin de préserver la stabilité des communications via la pile USB du Raspberry Pi.
Téléversement du firmware MicroPython sur l’ESP8266
La LED rouge branchée sur le GPIO #0 reste allumée durant toute l’opération. La LED bleue présente sur le module ESP8266 clignote régulièrement durant l’opération.
Une fois l’opération terminée, l’utilitaire esptool affiche le message « Hard resetting... » avant de réinitialiser l’ESP8266. Une fois le module réinitialisé, les deux LED rouge et bleue seront éteintes.
MicroPython est maintenant chargé et fonctionnel !
Flasher un NodeMCU
Dans le cas d’une plateforme comme NodeMCU, il est nécessaire d’activer manuellement le bootloader de l’ESP8266 en maintenant le bouton GPIO0 enfoncé (ou en mettant la broche GPIO #0 à la masse) pendant que le bouton « Reset » est pressé, puis relâché. Enfin, il faut relâcher le bouton du GPIO #0.
La commande pour flasher l’ESP est identique à celle du Feather.
Flasher un Wemos D1 mini
Le Wemos D1 mini dispose également d’un mécanisme d’auto-reset qui active le bootloader de l’ESP8266.
La procédure pour flasher un Wemos D1 mini est identique au Feather ESP8266.
Flasher un Wemos D1 Mini Pro
Le Wemos D1 Mini Pro dispose de 16 Mo de mémoire Flash. Plusieurs tests ont été nécessaires pour flasher correctement MicroPython sur l’ESP8266. Les présentes lignes ayant été ajoutées tardivement dans l’ouvrage, c’est la version 1.9.4 qui a été flashée sur l’ESP8266.
La commande utilisée pour flasher l’ESP8266 doit être adaptée afin de tenir compte de la mémoire Flash utilisée. À noter que, contre toute attente, c’est le paramètre mentionnant une mémoire Flash de 32 Mo qui permet de flasher correctement l’ESP8266.
esptool.py --port /dev/ttyUSB0 --baud 115200 write_flash -fm dio
-fs=32m 0 esp8266-20180511-v1.9.4.bin
MicroPython n’est pas seulement une implémentation de Python 3, il offre au microcontrôleur des services plus proches d’un système d’exploitation. MicroPython prend en charge le stockage et le transfert de fichiers dans la mémoire Flash, le support d’une ligne de commande interactive (dite REPL), le support de la connexion Wi-Fi/Ethernet et de services tels que Telnet, FTP, WebREPL.
La disponibilité des services dépend principalement de la plateforme utilisée (ESP8266, PyBoard, MicroBit, WiPy, SiPy, LoPy, Metro Express, Feather M0 Express, Circuit Playground Express). Certaines plateformes MicroPython branchées en USB sur un ordinateur sont capables d’exposer un système de fichier. C’est le cas de la carte Pyboard (plateforme MicroPython originelle) détectée comme un lecteur Flash USB sur lequel il est possible d’éditer directement les scripts Python à l’aide d’un éditeur de texte.
D’autres plateformes, comme l’ESP8266, ne disposent pas des ressources matérielles adéquates pour exposer un système de fichier USB (voire un service FTP natif). Il est néanmoins possible de transférer des scripts/fichiers sur le système de fichiers MicroPython par l’intermédiaire de l’interface série (USB-Série) ou via le service WebREPL (qu’il faudra activer via l’interface USB-Série).
Bien que l’ESP8266 soit un microcontrôleur disposant de deux cœurs et d’une interface Wi-Fi, il souffre de quelques limitations matérielles contraignantes. À titre d’exemple, l’ESP8266 ne dispose pas d’un support USB natif, ce qui empêche MicroPython d’exposer une partie de la mémoire flash interne comme un système de fichiers. MicroPython sur ESP8266 met néanmoins en place d’autres stratégies de communication.
Après le téléversement du firmware MicroPython sur l’ESP8266, le seul canal de communication disponible est l’interface série (UART). Cette interface série est facilement accessible sur un Feather Huzzah ESP8266 grâce au convertisseur USB-Série qu’il embarque.
Les points suivants guident le lecteur dans les différentes stratégies de communication à disposition.
MicroPython propose un service en ligne de commande appelé REPL (Read-Eval-Print-Loop, boucle de lecture, évaluation et affichage). REPL agit comme un terminal Python. Il permet de saisir des commandes Python et d’afficher le résultat de celles-ci.
REPL est pratique pour tester des instructions et les modules Python et offrira d’inestimables services durant les séances de débogage.
REPL est accessible via l’interface série et permet, à l’aide d’utilitaires appropriés, de transférer des fichiers sur l’ESP8266, de configurer la connexion Wi-Fi (via le fichier boot.py) et d’activer WebREPL (REPL via une interface web).
En installant l’utilitaire picocom (un logiciel terminal) sur le Raspberry Pi, il sera possible d’initier une session REPL via la connexion USB-Série (sur /dev/ttyUSB0).
sudo apt-get install picocom
Installation de picocom
Une fois installé, picocom permet d’ouvrir une session REPL à l’aide de la commande suivante :
sudo picocom /dev/ttyUSB0 -b115200
Une fois la session REPL ouverte, le firmware MicroPython affiche l’invite de commandes REPL « >>> ».
Ouverture de la session REPL sur le Feather Huzzah ESP8266
Il est maintenant possible de saisir des instructions Python et d’inspecter les résultats.
Il est parfois nécessaire de presser plusieurs fois la touche retour clavier pour obtenir l’invite de commandes « >>> ».
La capture d’écran ci-dessous présente plusieurs interactions avec REPL.
Exemple d’interaction avec REPL MicroPython
Les éléments suivants sont notables :
•.Une invite de commandes débute toujours par >>>.
•.Les résultats sont affichés sans préfixes (pas de >>> ou de ...).
•.Lorsqu’une commande s’étale sur plusieurs lignes (voir l’instruction for), MicroPython transforme l’invite de commandes en .... REPL prend en charge l’indentation du code. La saisie est exécutée lorsque la dernière ligne saisie ne présente plus d’indentation.
•.La fonction listdir() du module os permet d’obtenir une liste des fichiers présents dans l’espace de stockage. Le module os contient d’autres fonctions utiles dont la documentation est disponible.
REPL propose également un mode spécial appelé « paste mode » (mode coller) permettant de réaliser un copier/coller d’un bloc de code de plusieurs lignes et déjà indenté. Le « paste mode » est activé en pressant [Ctrl] E. L’interpréteur affiche alors l’invite de commandes ===. Après avoir collé le bloc de code dans l’interpréteur, la pression de [Ctrl] D fait repasser REPL en mode normal (>>>) et provoque l’interprétation du code qui a été collé en « paste mode ».
Pour quitter picocom, il faut presser la combinaison de touches [Ctrl] A suivie de [Ctrl] X.
Si Picocom se montre très utile pour les manipulations REPL, ce seul outil n’est pas suffisant pour conduire des développements. Les outils RShell et Ampy offrent des fonctionnalités avancées comme le transfert de fichiers.
RShell est un utilitaire Python écrit par Dave Hylands. RShell propose une interface utilisateur similaire au shell Linux et accepte des commandes de type shell (ls, cp, cd, cat, mkdir...) permettant de transférer des fichiers depuis/vers une carte MicroPython, d’afficher et éditer le contenu de fichiers, assurant la prise en charge des sessions REPL (présenté ci-avant).
RShell est capable d’exploiter les connexions séries et/ou Telnet pour communiquer avec une ou plusieurs cartes MicroPython.
Bien qu’il existe d’autres utilitaires, dont Ampy présenté plus loin, la maîtrise d’un outil comme RShell apporte de réels avantages.
RShell utilise Python 3 et s’installe à l’aide de l’utilitaire pip3. Saisissez la commande suivante sur le Raspberry Pi pour installer RShell.
sudo pip3 install rshell
Installation de RShell
RShell accepte de nombreux paramètres en ligne de commande, les plus importants sont :
rshell --port nom_port --baud debit --buffer-size taille_buffer --editor editeur
•.nom_port : périphérique série (/dev/ttyUSBx) sur lequel est connecté une carte MicroPython. RShell établira immédiatement la communication avec la carte.
•.debit : permet de fixer le débit de la communication (en baud). 115200 bauds pour un ESP8266.
•.taille_buffer : permet de modifier la taille de la mémoire tampon utilisée durant les échanges de données avec la carte MicroPython.
•.editeur : permet de mentionner l’éditeur à utiliser avec la commande edit de RShell. Nano est un éditeur simple d’emploi pouvant être utilisé dans une session terminal.
La commande suivante, saisie dans le terminal, démarre RShell en enregistrant la carte MicroPython présente sur le périphérique /dev/ttyUSB0.
rshell --port /dev/ttyUSB0 --baud 115200 --buffer-size 128 --editor nano
Le paramètre buffer-size (fixé à 128 octets) est capital lorsque RShell est utilisé avec des ESP8266 sous MicroPython. La valeur par défaut de ce paramètre (512 octets) provoque un écrasement de la mémoire Flash de l’ESP8266, ce qui détruit le système de fichiers MicroPython. La carte n’étant plus utilisable en l’état, il sera nécessaire de reflasher MicroPython sur l’ESP8266 pour retrouver un système fonctionnel.
Une fois RShell démarré, l’invite de commandes est modifiée et indique le répertoire courant (soit /home/pi dans le cas présent).
Ligne de commande RShell
RShell utilise une notation particulière du système de fichiers afin de distinguer les fichiers locaux des fichiers stockés sur la plateforme MicroPython. Tous les chemins débutants par /pyboard feront référence à la carte MicroPython (l’ESP8266 dans le cas présent). Plusieurs cartes pouvant être branchées en même temps, RShell proposera ensuite /pyboard1, /pyboard2, etc.
RShell propose des commandes shell Linux standard comme ls, cat, cd, cp, edit, rm, mkdir, etc. et quelques autres commandes auxquelles viennent s’ajouter des commandes propres à RShell. À titre d’exemple, les commandes repl, connect et boards permettent respectivement de démarrer une session REPL, d’ajouter/enregistrer une nouvelle carte MicroPython et de lister les cartes actuellement accessibles.
La syntaxe de la commande RShell et des commandes qu’il supporte est documentée :
•.en français sur https://wiki.mchobby.be/index.php?title=MicroPython-Hack-RShell.
•.en anglais sur https://github.com/dhylands/rshell.
Les commandes RShell suivantes affichent la liste des fichiers présents sur l’ESP8266 puis le contenu du fichier boot.py présent sur la carte ESP8266 :
ls /pyboard
cat /pyboard/boot.py
Afficher le contenu d’un fichier stocké sur l’ESP8266
Presser la combinaison [Ctrl] D pour quitter RShell.
La carte Feather ESP8266 est équipée d’une LED rouge raccordée sur le GPIO 0. Cette section du tutoriel se propose de poursuivre la découverte de RShell en manipulant cette LED dont logique de contrôle se trouve inversée.
Schéma type du raccordement de la LED rouge branchée sur le GPIO 0
Voici les différents cas de figure de contrôle de la LED.
•.GPIO 0 au niveau bas : il existe une différence de potentiel entre le +3,3 V et GPIO 0 qui entraîne la circulation d’un courant. Ce courant peut circuler du +3,3 V vers le GPIO0 en passant par la diode LED. La LED s’illumine. La broche GPIO absorbe le courant, ce qui se dit sink current en anglais.
•.GPIO 0 est au niveau haut : le potentiel du GPIO étant à 3,3 V, il n’existe aucune différence de potentiel entre le +3,3 V et le GPIO 0. Il n’y a donc aucune circulation de courant et la LED reste éteinte.
Voici qui termine ce petit aparté concernant le GPIO 0 et la LED rouge.
Les commandes suivantes, exécutées depuis RShell, permettent de créer le fichier test.py et d’en éditer le contenu avec l’éditeur de texte Nano. Le ! indique à RShell que la commande doit être exécutée directement par le shell Linux. Le fichier test.py est stocké dans le répertoire /home/pi du Raspberry Pi.
!touch test.py
!nano test.py
Création du fichier test.py depuis RShell
Saisissez le code Python suivant dans l’éditeur Nano.
from machine import Pin
p = Pin(0, Pin.OUT ) # Broche attachée a la LED rouge
p.value( 0 ) # Allume la led Rouge / logique inversée
Édition du contenu du fichier test.py
Utiliser la combinaison [Ctrl] O pour sauver le contenu du fichier puis [Ctrl] X pour quitter l’éditeur Nano (et revenir dans la session RShell).
Le fichier test.py est maintenant disponible sur le système de fichiers du Raspberry Pi. La commande cp permet de faire une copie de ce fichier sur l’ESP8266. Les commandes ls et cat permettent ensuite de vérifier que le transfert s’est déroulé correctement.
cp test.py /pyboard
ls /pyboard
cat /pyboard/test.py
Transfert d’un fichier vers la carte ESP8266
La commande cp test.py /pyboard copie le fichier test.py disponible dans le répertoire courant (/home/pi en l’occurrence) sur l’ESP8266 (identifié par le /pyboard) et plus précisément dans le répertoire racine étant donné qu’aucun chemin n’est précisé. La commande cp test.py /pyboard/test.py aurait produit le même résultat.
La commande ls /pyboard affiche les fichiers disponibles sur la carte ESP8266. Le résultat de la commande affiche le fichier test.py nouvellement transféré sur l’ESP8266.
Pour finir, la commande cat /pyboard/test.py affiche le contenu du fichier tel qu’il est stocké sur la carte.
REPL sous RShell
Une des fonctionnalités intéressantes de RSHell est de pouvoir invoquer REPL directement depuis RShell puis de revenir à RShell une fois la session REPL terminée.
La commande repl permet d’initier la session REPL et de saisir du code Python interprété directement sur la plateforme MicroPython.
L’exemple suivant montre le passage dans une session REPL et la saisie de quelques instructions Python. L’instruction os.listdir() permet de lister les fichiers présents dans la mémoire flash où il est possible d’identifier le fichier test.py récemment transféré.
Ouverture d’une session REPL depuis RShell
Il est possible de quitter la session REPL et revenir à RShell en pressant la combinaison de touches [Ctrl] X (il est parfois nécessaire de répéter l’opération plusieurs fois). La combinaison de touches [Ctrl] D saisie dans une session REPL effectue une réinitialisation logicielle de la plateforme MicroPython (dite « Soft Reboot »).
La session REPL permet de charger un module Python à la volée. Cela permet de tester facilement les fonctions et les classes d’un module utilisateur en cours d’écriture. Si le module contient du code exécutable (ce qui est le cas de test.py), alors ce code sera exécuté à l’importation du module.
Pour rappel, le fichier test.py contient le code suivant :
from machine import Pin
p = Pin(0, Pin.OUT ) # Broche attachée a la LED rouge
p.value( 0 ) # Allumer la LED rouge / logique inversée
Saisissez les instructions suivantes dans la session REPL :
import test
dir()
dir(test)
test.p.value()
Importer un module Python
La première instruction import test importe le module test.py et en exécute le contenu. Ce qui a pour effet d’allumer la LED rouge attachée sur la broche GPIO #0.
Résultat de l’exécution du fichier test.py
La seconde instruction dir() retourne une liste de noms des objets/variables de portée globale disponibles pour l’interpréteur. La liste reprend le module « test » ainsi que le module « uos » importé avec la commande import os (le « u » indique qu’il s’agit d’une bibliothèque du firmware MicroPython).
La troisième instruction dir(test) affiche la liste des noms rattachés au module test (donc les déclarations contenues dans le fichier test.py). Cette fois, la liste reprend la classe Pin et la variable p attachée au GPIO #0.
La quatrième instruction test.p.value() permet d’interroger directement l’état de la broche par l’intermédiaire de la variable p du module test. La valeur 0 retournée indique que la broche est au niveau bas, signifiant que la LED rouge est allumée (logique inversée).
La LED peut être éteinte en saisissant l’instruction suivante sur l’invite REPL.
test.p.value( 1 )
Pressez [Ctrl] X pour quitter la session REPL et revenir à l’invite de commandes RShell.
Retour à l’invite RShell
Pressez [Ctrl] D pour quitter l’invite de commandes RShell et revenir au shell Linux.
Ampy (Adafruit MicroPython) est un utilitaire alternatif à RShell. Ampy est produit par Adafruit Industries pour le support de ses différentes plateformes fonctionnant sous MicroPython ou CircuitPython (une version de MicroPython personnalisée par Adafruit qui intègre déjà des pilotes pour différents senseurs et composants).
Ampy est un utilitaire Python tout comme RShell mais les ressemblances s’arrêtent là. Si RShell se présente comme un couteau suisse très pratique, Ampy opte pour la philosophie du « plus simple possible ». Ainsi, Ampy propose un utilitaire en ligne de commande uniquement centré sur la manipulation de fichiers, et l’exécution de code envoyé vers la plateforme MicroPython. Au contraire de RShell, Ampy ne propose pas de prise en charge du REPL, il faudra alors revenir à un utilitaire comme picocom déjà présenté dans ce chapitre.
Cette simplicité se retrouve également dans la syntaxe de l’utilitaire, Ampy est donc un outil idéal pour les premiers pas. Il est important de noter que Ampy n’exploite que la communication série pour dialoguer avec la plateforme MicroPython.
Cette section couvre l’installation et l’utilisation de Ampy.
Ampy utilise aussi bien Python 2.7 que Python 3. Étant donné qu’un Raspberry Pi est utilisé comme hôte, Ampy peut être installé à l’aide de la commande suivante :
sudo pip install adafruit-ampy
Installation de Ampy sur un Raspberry Pi
Ampy est un utilitaire en ligne de commande utilisant des OPTIONS pour configurer la connexion série et des COMMANDES pour manipuler le système de fichier. Le texte ci-dessous reprend une version simplifiée du texte d’aide :
ampy [OPTIONS] COMMANDES [ARGUMENTS]...
Options:
-p PORT : Obligatoire. Nom du port série à utiliser.
-b BAUD : Débit de la connexion série (115200 par défaut).
--version : Affiche la version du programme.
--help : Affiche le message d’aide complet.
Commandes:
get Télécharge un fichier présent sur la carte.
ls Liste le contenu d’un répertoire présent sur la carte.
mkdir Crée un répertoire sur la carte.
put Téléverse un fichier (ou un répertoire et son contenu)
sur la carte.
reset Effectue une réinitialisation logicielle de l’interpréteur
MicroPython présent sur la carte.
rm Enlève un fichier de la carte.
rmdir Enlève un répertoire (et son contenu) de la carte.
run Exécute un script sur la carte et affiche les résultats.
La syntaxe de la commande ampy et les commandes sont documentées :
•.en français sur : https://wiki.mchobby.be/index.php?title=FEATHER-CHARGER-FICHIER-MICROPYTHON
•.en anglais sur : https://learn.adafruit.com/micropython-basics-load-files-and-run-code
Ayant déjà identifié le port série associé à la carte MicroPython (ttyUSB0, cf. Charger le firmware MicroPython dans ce chapitre), l’exemple suivant se propose d’exécuter un script sur la plateforme MicroPython branchée sur le port USB du Raspberry Pi. Le résultat de l’exécution sera récupéré par Ampy et affiché sur la console.
Saisissez les commandes suivantes pour créer le fichier ampy-test.py dans le répertoire local du Raspberry Pi et en éditer le contenu avec Nano.
touch ampy-test.py
nano ampy-test.py
Puis saisissez le code Python suivant dans l’éditeur de texte Nano.
print( ’MicroPython compte par 2 jusque 20’ )
for compteur in range( 0, 21, 2 ):
print( compteur )
print( ’Voilà c\’est fait !’ )
Édition du fichier ampy-test.py
Utiliser la combinaison [Ctrl] O pour sauver le contenu du fichier puis [Ctrl] X pour quitter l’éditeur Nano (et revenir à l’invite de commandes).
Pour finir, la commande ci-dessous exécute le contenu du fichier en mode REPL sur la plateforme MicroPython et récupère le résultat produit par l’exécution.
ampy -p /dev/ttyUSB0 run ampy-test.py
Ce qui produit le résultat suivant :
Exécution du script ampy-test.py sur l’ESP8266
L’utilitaire Ampy permet également d’effectuer des opérations sur le système de fichiers MicroPython. Il sera ainsi possible de transférer des fichiers depuis le Raspberry Pi (ou autre ordinateur) vers la carte MicroPython (et vice-versa). En outre, Ampy permet la gestion de l’arborescence des fichiers et de répertoires sur la carte.
Avec la commande put, Ampy copiera un fichier depuis l’ordinateur vers la plateforme MicroPython. L’exemple suivant copie le fichier ampy-test.py précédemment créé dans le répertoire racine de la carte MicroPython en lui assignant un nouveau nom.
ampy -p /dev/ttyUSB0 put ampy-test.py ampytest.py
Le transfert de fichier avec la commande put écrase toujours le contenu du fichier sur la carte MicroPython.
Le tiret - a été enlevé du nom du fichier durant de la copie. Si le système de fichier supporte très bien le tiret dans un nom de fichier, l’importation de bibliothèque Python ne tolère pas ce caractère dans un nom de bibliothèque !
Il est possible, de lister les fichiers présents dans le répertoire racine (ou sous-répertoire) de la carte en utilisant la commande ls.
ampy -p /dev/ttyUSB0 ls
Ce qui affiche la liste des fichiers dans laquelle il est possible d’identifier ampytest.py mais également le fichier d’exemple créé durant la présentation de RShell.
Résultat de la commande ampy « ls »
L’utilitaire picocom, permet de démarrer une session REPL sur la carte MicroPython et d’exécuter le contenu du script ampytest.py directement depuis la carte MicroPython.
sudo picocom /dev/ttyUSB0 -b115200
Une fois le terminal démarré et l’invite de commandes MicroPython visible >>>, la saisie de l’instruction import ampytest chargera le fichier ampytest.py pour en exécuter le contenu.
Il est parfois nécessaire de presser la touche retour clavier [Enter] pour obtenir l’invite de commandes REPL >>>.
Exécution du contenu de ampytest.py depuis l’invite REPL
Presser la combinaison de touches [Ctrl] A et [Ctrl] X pour quitter picocom et revenir à l’invite de commandes du système d’exploitation.
Ampy permet de récupérer des fichiers présents sur la carte MicroPython en utilisant la commande get.
Lorsque le nom de fichier local est précisé en deuxième paramètre, la commande get fait une copie du fichier de la carte MicroPython vers le système de fichier local. Si aucun nom de fichier n’est précisé pour la copie locale (deuxième paramètre omis), alors Ampy affiche le contenu du fichier vers le périphérique de sortie (donc le terminal). Cette particularité permet de consulter rapidement le contenu d’un fichier présent sur la carte MicroPython.
L’exemple suivant affiche le contenu du fichier boot.py présent sur la carte, puis en fait une copie vers le système de fichier local du Raspberry Pi (en précisant le nom que doit avoir le fichier copié). Pour finir, la commande ls b*.py permet de vérifier la présence de la copie locale avant d’utiliser cat pour en afficher le contenu.
ampy -p /dev/ttyUSB0 get boot.py
ampy -p /dev/ttyUSB0 get boot.py boot.py
ls b*.py
cat boot.py
Utilisation de la commande ampy « get »
Pour terminer cette présentation de Ampy, l’exemple suivant utilise la commande rm (de l’anglais remove) pour enlever le fichier ampytest.py présent sur la carte MicroPython. La commande ampy ls permet de vérifier l’effacement du fichier sur la carte.
ampy -p /dev/ttyUSB0 rm ampytest.py
ampy -p /dev/ttyUSB0 ls
Utilisation de la commande ampy « rm »
Voilà qui termine l’introduction des outils permettant de dialoguer avec les plateformes ESP8266 sous MicroPython.
WebREPL est une fonctionnalité avancée de MicroPython sur ESP8266 qui permet d’initier une session REPL par l’intermédiaire d’une interface web exécutée au sein d’un navigateur internet. WebREPL s’appuie sur le service Python WebREPL qu’il faut activer sur l’ESP8266 (sécurité oblige !). Ce service expose un WebSocket permettant à la partie cliente de WebREPL d’interagir avec la plateforme MicroPython.
L’activation du service WebREPL s’effectue via une session REPL, donc via la liaison série. L’activation du service permet de définir le mot de passe qui protégera ensuite les sessions WebREPL accessibles via l’interface Wi-Fi.
Le client WebREPL se présente comme suit :
Interface WebREPL
L’interface WebREPL propose les options suivantes :
1. | Zone de saisie de l’adresse de la carte MicroPython. Cette adresse est préfixée par « ws:// », car il s’agit d’une connexion Web Socket. Le suffixe «:8266 » indique le numéro de port à utiliser (8266 pour rappeler la plateforme ESP8266). L’adresse IP 192.168.4.1 est l’adresse par défaut des ESP8266 démarrant en mode point d’accès. Si l’ESP8266 est connecté sur le réseau Wi-Fi domestique, alors l’adresse IP de l’ESP8266 sur ce réseau domestique peut être utilisée. Celle-ci se présente souvent sous la forme 192.168.1.x. Pour finir, lorsque la résolution DNS le permet, il est également possible d’employer le nom d’hôte de la plateforme MicroPython. Celle-ci se présente sous la forme ESP_xxxxxx où xxxxxx sont les 3 derniers octets de l’adresse MAC de l’interface Wi-Fi de l’ESP8266. Ex. : ws://ESP_88BB0E:8266. |
2. | Bouton Connect/Disconnect : permet d’établir (ou de terminer) une connexion avec le Web Socket de l’ESP8266. Le mot de passe WebREPL est requis dès la connexion établie. Les autres parties de l’interface WebREPL peuvent ensuite être utilisées. |
3. | Envoi d’un fichier vers MicroPython : permet d’envoyer un fichier (un script Python) dans le répertoire racine de l’ESP8266. Si le fichier existe déjà sur la carte, alors son contenu est écrasé. |
4. | Télécharger un fichier depuis MicroPython : permet de récupérer un fichier stocké dans le répertoire racine de l’ESP8266 dès lors que son nom est connu. Le fichier téléchargé dans le navigateur est disponible dans la zone des téléchargements du navigateur. |
5. | Zone de saisie REPL. Une fois le client WebREPL connecté sur l’ESP8266, cette zone texte permet de saisir des instructions et d’afficher des résultats. L’invite REPL et les comportements restent identiques à ceux d’une simple session REPL. |
Pour utiliser WebREPL, il est nécessaire d’activer et de de configurer le démon WebREPL sur la carte ESP8266. Ce démon est un service qui fonctionne en arrière-plan.
La configuration se limite à un mot de passe.
Après avoir établi une session REPL série, la configuration est réalisée à l’aide de la commande suivante :
import webrepl_setup
Cela importe et exécute le module de configuration. La première question demande s’il faut activer le démon WebREPL sur l’ESP8266. Répondez « E » (de l’anglais Enable) pour activer le démon.
Un mot de passe doit ensuite être saisi (et confirmé). Ce dernier servira à protéger la connexion WebREPL.
Pour finir, l’outil de configuration demande s’il faut redémarrer la plateforme ESP8266 (afin d’activer le démon WebREPL). Répondez « Y » pour redémarrer la plateforme, ce qui aura pour effet de réinitialiser la connexion série.
Activation du démon WebREPL sur la plateforme ESP8266
Voilà, le WebSocket WebREPL est maintenant actif sur l’ESP8266.
Bien que le script de configuration propose de procéder à une réinitialisation logicielle de la carte (Soft Reboot), cette opération n’est pas suffisante pour démarrer correctement le démon WebREPL. Le redémarrage logiciel, qu’il est aussi possible d’initier avec [Ctrl] D, affiche systématiquement le message « WebREPL is not configured, run ’import webrepl_setup’ ».
Une réinitialisation matérielle est nécessaire pour démarrer le démon WebREPL. Il faut presser le bouton Reset de la carte.
Réinitialisation matérielle de la carte avec le bouton Reset (Rst)
Une fois WebREPL activé, l’ESP8266 contient un nouveau fichier nommé webrepl_cfg.py contenant la constante PASS définissant le mot de passe WebREPL.
Les instructions Python suivantes saisies en REPL listent les fichiers présents sur la carte puis affichent le contenu du fichier webrepl_cfg.py.
Afficher le contenu du fichier de configuration webrepl_cfg.py
Le contenu du fichier est retourné sous la forme d’une chaîne de caractères. Le \n indique un retour à la ligne. Le fichier webrepl_cfg.py ne contient donc qu’une seule instruction Python qui est PASS = ’8266-100’.
Il est donc possible de consulter ou modifier le mot de passe à volonté en passant par la liaison série. Les utilitaires RShell et Ampy proposent des solutions de manipulation de fichier permettant de copier le fichier webrepl_cfg.py sur le Raspberry Pi/ordinateur pour en modifier le contenu avant de renvoyer le fichier sur l’ESP8266. Il sera nécessaire de redémarrer le démon WebREPL en réinitialisant l’ESP8266.
Les ressources de la plateforme ESP8266 étant limitées, la plateforme MicroPython ne stocke pas le client WebREPL dans sa mémoire Flash. Le client WebREPL ne peut pas être téléchargé depuis un serveur HTTP activé sur l’ESP8266 ! Le seul élément que la plateforme ESP8266 embarque est le WebSocket WebREPL.
Il est nécessaire de charger le client WebREPL dans le navigateur web depuis l’une des sources suivantes :
Chargez le client directement depuis http://micropython.org/webrepl/.
Obtenez une copie de WebREPL depuis le dépôt GitHub (https://github.com/micropython/webrepl) puis chargez-la dans le navigateur web depuis le système de fichier de votre ordinateur.
La présence de plusieurs cartes ESP8266 connectées sur le réseau Wi-Fi domestique peut rendre pénible l’identification d’un objet sur la base d’une adresse IP dynamique.
Connaître le nom d’hôte (nom sur le réseau) de la carte ESP8266 est commode pour contacter la carte en utilisant ce nom en lieu et place de l’adresse IP. Par ailleurs, connaître l’adresse MAC des différents ESP8266 permet d’identifier chaque carte de manière univoque.
Les lignes suivantes, saisies sur une invite REPL, permettent d’extraire une partie de l’adresse MAC et de reconstituer le nom d’hôte de la carte ESP8266.
from network import WLAN
wlan=WLAN()
wlan.config(’mac’)
Ce qui produit le résultat suivant :
Obtention de l’adresse MAC de la carte ESP8266
Le tableau d’octets b’\\\xcf\x7f\xef\xb1\xd3’ renvoyé pour la configuration de l’adresse MAC (adresse physique de la carte sur le réseau) indique que les 5 derniers octets sont CF:7F:EF:B1:D3.
La carte ESP8266 utilise un nom d’hôte composé de « ESP_ » suivi des 3 derniers octets de l’adresse MAC. Dans le cas présent, le nom d’hôte sera ESP_EFB1D3.
Par défaut, l’interface Wi-Fi de l’ESP8266 démarre en mode point d’accès. Cela signifie que la carte crée son propre réseau Wi-Fi sur lequel un ordinateur, un smartphone, etc. peut se connecter en tant que station.
Le nom du réseau Wi-Fi créé par la carte ESP8266 est « MicroPython-xxxxxx » où les xxxx sont une partie de l’adresse MAC de l’ESP8266.
ESP8266 en mode point d’accès
L’interface Wi-Fi de l’ESP8266 peut aussi être configurée en mode station (STA) afin de connecter la carte sur un réseau Wi-Fi existant. Ce point spécifique est abordé plus loin dans le chapitre.
En vue d’établir une connexion WebREPL, il est recommandé d’utiliser un ordinateur disposant d’un navigateur Firefox ou Chrome.
La première étape consiste à se connecter sur le point d’accès MicroPython en utilisant le gestionnaire réseau du système d’exploitation (Linux Mint dans la capture ci-dessous).
PC se connectant sur le point d’accès MicroPython créé par l’ESP8266
Le mot de passe par défaut de la connexion Wi-Fi est micropythoN (avec le N en majuscule).
Saisie du mot de passe Wi-Fi du réseau point d’accès de ESP8266
Session WebREPL en mode point d’accès
Le client WebREPL est disponible sur http://micropython.org/webrepl/. Il peut également être téléchargé sous forme d’archive depuis le dépôt GitHub (https://github.com/micropython/webrepl) dont il faut extraire le contenu avant de charger le fichier webrepl.html avec votre navigateur.
Fonctionnant en mode point d’accès, l’adresse IP du module ESP8266 est 192.168.4.1, de sorte qu’il n’est pas nécessaire de modifier l’adresse dans le client avant de presser sur le bouton Connect.
Connexion sur ESP8266 en mode point d’accès
Une fois le bouton Connect pressé, le module ESP8266 invite le client à saisir le mot de passe WebREPL (celui configuré durant l’activation du démon WebREPL sur la carte).
L’invite REPL (>>>) est disponible après la saisie du mot de passe.
Invite REPL sur le client WebREPL
La section précédente a couvert le fonctionnement du module ESP8266 en mode point d’accès. Dans ce mode, l’ESP8266 crée son propre réseau Wi-Fi sur lequel viennent se connecter ordinateurs, smartphones, tablettes, etc.
Le mode point d’accès (AP) est le mode de fonctionnement par défaut du module ESP8266. Ce n’est cependant pas le mode de fonctionnement le plus intéressant pour exploiter un réseau de capteurs à base d’ESP8266.
Cette section aborde la découverte et l’utilisation du mode station (STA).
ESP8266 en mode STAtion
Le mode station (STA) permet au module ESP8266 de se connecter sur un réseau Wi-Fi existant. Il est ainsi possible d’avoir plusieurs ESP8266 cohabitant sur le réseau local et tous accessibles depuis n’importe quel ordinateur présent sur le même réseau.
L’utilisation du réseau local s’avère également plus appropriée dans le cadre de ce projet étant donné que les différents ESP8266 publieront des données vers un serveur MQTT (un Raspberry Pi également enregistré sur le réseau local).
Il est tentant de réutiliser la connexion WebREPL en mode point d’accès (PA) pour tester le code ci-dessous. Il est néanmoins recommandé d’utiliser une connexion REPL standard (via connexion série) pour effectuer les opérations de connexion au réseau.
Les commandes suivantes, saisies sur une invite REPL, permettent de détecter les réseaux Wi-Fi à portée de l’ESP8266.
01: from network import *
02: wlan = WLAN( STA_IF )
03: wlan.active( True )
03: for wifi in wlan.scan():
04: print( wifi )
Ligne 1 : permet d’importer les classes et constantes nécessaires.
Ligne 2 : crée un objet WLAN permettant d’interagir avec l’interface Wi-Fi du module ESP8266. Le paramètre STA_IF indique que l’interface doit être configurée en mode STATION (client réseau).
Ligne 3 : activation de l’interface STA.
Ligne 4 : détection des réseaux à proximité. La méthode scan() retourne une liste de tuples. Ces tuples sont ensuite affichés par la commande print(). Les réseaux Wi-Fi annoncent régulièrement leur identifiant SSID, de fait le contenu de la méthode scan() peut évoluer d’un appel à l’autre.
Détection des réseaux à proximité de l’ESP8266
Chaque tuple reprend les informations suivantes :
•.SSID : le nom du réseau sans fil.
•.BSSID : adresse MAC du point d’accès au format binaire. Il est possible d’en obtenir la forme ASCII en utilisant ubinascii.hexlify().
•.channel : canal de fréquence utilisé pour la transmission.
•.RSSI : indicateur de force de signal (-30 dBm pour un niveau élevé, -90 dBm pour le niveau minimum d’exploitation du signal).
•.authmode : correspond aux constantes définies dans le module network.
•.AUTH_OPEN : 0, réseau ouvert
•.AUTH_WEP : 1, protocole de sécurité WEP
•.AUTH_WPA_PSK : 2, protocole de sécurité WPA
•.AUTH_WPA2_PSK : 3, protocole de sécurité WPA2
•.AUTH_WPA_WPA2_PSK : 4
•.hidden : indique si le réseau est masqué (1) ou visible (0).
La plupart des modems-routeurs récents sont capables de fonctionner en mode masqué. Cela signifie qu’ils n’annoncent pas publiquement le réseau Wi-Fi (leur SSID). A priori, ces modems-routeurs ne seront pas identifiés lors de la détection des réseaux Wi-Fi à l’aide de la fonction WLAN.scan(). Cela n’empêchera pas de s’y connecter à condition de connaître le BSSID (adresse MAC) du réseau en question. Ce BSSID sera alors utilisé en lieu et place du SSID lors de l’établissement de la connexion Wi-Fi depuis MicroPython (utilisation d’un paramètre BSSID et non du paramètre SSID).
Lors des premières expérimentations, il est recommandé d’utiliser un réseau non masqué afin de ne pas multiplier les obstacles et difficultés.
Ayant identifié le réseau à utiliser parmi les réseaux Wi-Fi disponibles (ex. : ATCG103) et connaissant le mot de passe associé, les instructions suivantes permettent de connecter le module ESP8266 au réseau Wi-Fi disposant d’un service DHCP (allocation dynamique d’adresse IP).
La section ci-dessous reprend une transcription de la session REPL.
01 >>> import network
02 >>> wlan = network.WLAN( network.STA_IF )
03 >>> wlan.active( True )
04 >>> wlan.connect( "ATCG103", "motdepasse" )
05 >>> while not wlan.isconnected():
06 ... pass
07 ...
08 ...
09 ...
10 >>> print( wlan.isconnected() )
11 True
12 >>> print( wlan.ifconfig() )
13 (’192.168.1.9’, ’255.255.255.0’, ’192.168.1.1’, ’192.168.1.1’)
Ligne 1 : importer le module network pour avoir accès aux classes et constantes nécessaires.
Ligne 2 : créer un objet de type WLAN en activant l’interface en mode STATION (STA_IF : STAtion InterFace) afin de se connecter sur un réseau Wi-Fi.
Ligne 3 : activer l’interface Wi-Fi.
Ligne 4 : connexion sur le réseau Wi-Fi ayant le SSID « ATCG103 » avec le mot de passe mentionné.
La syntaxe d’une connexion avec BSSID serait :
wlan.connect( bssid=b’valeur_du_bssid’, password="motdepasse" )
Lignes 5 et suivante : boucle d’attente jusqu’à ce que la connexion soit établie.
Ligne 12 : affichage de la configuration réseau où l’on retrouve :
•.l’adresse IP allouée par le service DHCP du réseau
•.le masque réseau
•.l’adresse de la passerelle (celle du modem-routeur dans le cas d’un réseau Wi-Fi domestique)
•.l’adresse du service DNS (généralement, le modem-routeur dans le cas d’un réseau Wi-Fi domestique)
Le module Wi-Fi de l’ESP8266 restaure automatiquement la configuration Wi-Fi après chaque démarrage (cycle d’alimentation). Cela signifie que le module rétablit la connexion Wi-Fi à partir des dernières informations d’authentification connues.
Une fois la connexion établie avec le réseau Wi-Fi, il est nécessaire de réinitialiser matériellement la plateforme MicroPython pour bénéficier du service WebREPL sur le réseau local (si, bien entendu, le service WebREPL est actif). Conformément à la remarque ci-dessus, la connexion Wi-Fi sera automatiquement ré-établie avec le réseau Wi-Fi.
Une fois l’ESP8266 connecté sur le réseau et promptement réinitialisé, il est possible d’établir une connexion WebREPL au travers du réseau local.
Il suffit alors de remplacer le 192.168.4.1 par l’adresse IP sur le réseau (soit 192.168.1.9) avant de presser le bouton Connect. Dans le cas présent, l’adresse sera :
ws://192.168.1.9:8266
Comme vous pouvez le constater sur la capture suivante, il est possible de réinterroger l’interface réseau pour en extraire la configuration actuelle.
Session WebREPL sur le réseau local
Le service DHCP assigne automatiquement une adresse IP et obtenir une session sur la base d’une adresse IP variable n’est pas forcément confortable.
Sur un réseau local, il est également possible d’établir la connexion WebREPL en utilisant le nom d’hôte de l’ESP8266.
Dans l’exemple ci-dessous, le nom d’hôte ESP_EFB1D3 est utilisé pour établir la session WebREPL.
Utilisation du nom d’hôte pour contacter l’ESP8266
La résolution du nom d’hôte des ESP8266 sur le réseau local n’est néanmoins pas toujours possible. Il est possible d’identifier l’adresse IP dynamique à partir de l’adresse MAC connue de l’ESP8266 en utilisant l’utilitaire nmap (voir plus loin) disponible sur le Raspberry Pi.
Le navigateur Chrome utilise exclusivement les serveurs DNS de Google pour la résolution des noms. Sous Chrome/Chromium, il est donc impossible d’atteindre un ESP8266 en utilisant son nom d’hôte.
Maintenant que l’ESP8266 est connecté en mode station sur un réseau Wi-Fi, il n’est plus utile de préserver le point d’accès Wi-Fi (AP) sur le module ESP8266.
Les deux interfaces STA et AP peuvent coexister en même temps sur le module. Une fois connecté en mode STA, il n’est pas utile d’annoncer la présence du module ESP8266 dans le voisinage en maintenant active l’interface AP (Point d’Accès).
Le code suivant vérifie l’état de la connexion en mode station, puis désactive l’interface point d’accès (AP) sur le module ESP8266.
01: Import network
02: sta = network.WLAN( network.STA_IF )
03: if sta.active() and (sta.status() == network.STAT_GOT_IP ):
04: ap = network.WLAN( network.AP_IF )
05: if ap.active():
06: ap.active(False)
Ligne 1 : importer le module network pour avoir accès aux classes et constantes nécessaires.
Ligne 2 : obtenir une référence sur l’interface station.
Ligne 3 : vérifier que l’interface station est active et a reçu une adresse IP.
Ligne 4 : obtenir une référence sur l’interface Point d’accès.
Lignes 5 - 6 : si le point d’accès est actif, alors le désactiver.
À la suite de ces instructions, le point d’accès Wi-Fi « MicroPython-xxx » sera désactivé et disparaîtra de la liste des réseaux Wi-Fi disponibles.
En installant l’utilitaire nmap sur le Raspberry Pi, il est possible de scanner le réseau à la recherche des interfaces qui y sont actives. Nmap permet d’identifier les adresses IP correspondantes aux adresses MAC relevées sur les différents modules ESP8266.
La commande suivante installe l’utilitaire nmap :
sudo apt-get install nmap
Une fois nmap installé, la commande suivante lance une recherche d’adresses IP (par ping) sur un réseau 192.168.1.x. Cette opération affiche les adresses IP, les adresses MAC correspondantes et les hostname si disponibles.
sudo nmap -sn 192.168.1.0/24
Les ESP8266 ont une adresse MAC commençant par « 5C:CF ».
Dans l’extrait du résultat Nmap ci-dessous, il est possible d’identifier l’adresse IP 192.168.1.9 correspondant à notre ESP_EDB1D3 (adresse MAC terminant par ED:B1:D3).
pi@pythonic:~ $ sudo nmap -sn 192.168.1.0/24
Starting Nmap 6.47 ( http://nmap.org ) at 2017-09-18 21:16 UTC
...
Nmap scan report for 192.168.1.9
Host is up (0.062s latency).
MAC Address: 5C:CF:7F:EF:B1:D3 (Unknown)
...
Nmap done: 256 IP addresses (18 hosts up) scanned in 4.87 seconds
La séquence de démarrage MicroPython est initiée à chaque mise sous tension, chaque réinitialisation matérielle et chaque réinitialisation logicielle ([Ctrl] D) de la plateforme.
La séquence de démarrage met en œuvre deux fichiers importants qui doivent se trouver dans la racine du système de fichiers MicroPython. Il s’agit des fichiers boot.py et main.py.
La plateforme MicroPython doit contenir un fichier boot.py présent dans la racine du système de fichier.
Le fichier boot.py est le premier fichier chargé par MicroPython. Ce dernier doit contenir les instructions destinées à la configuration de bas niveau de la plateforme. Le contenu de boot.py doit être concis, son contenu est destiné à finaliser le démarrage de la carte MicroPython.
Dans le cadre d’un module ESP8266, boot.py contiendra le code de configuration de l’interface Wi-Fi.
Une fois le contenu du fichier boot.py traité par MicroPython, la plateforme chargera et exécutera le contenu du fichier main.py.
MicroPython poursuit la séquence de démarrage même si le fichier boot.py provoque une erreur.
Attention : la séquence de démarrage est suspendue aussi longtemps que le contenu du fichier boot.py est exécuté ! Une boucle infinie dans boot.py bloquera la séquence de démarrage de la plateforme. Cela peut avoir une incidence sur des outils comme RShell étant donné que ceux-ci exécutent une réinitialisation logicielle en début de session. En cas de blocage dans boot.py, reflasher la plateforme sera la solution la plus efficace pour retrouver un système opérationnel.
Si ce fichier existe à la racine du système de fichiers, ce dernier sera automatiquement chargé et exécuté à la fin de la séquence de démarrage.
Le fichier main.py est destiné à recevoir le programme utilisateur (script Python) qui est exécuté à chaque démarrage de la plateforme MicroPython.
Voici une suggestion de fichier boot.py pour un module ESP8266 connecté en mode station sur le réseau Wi-Fi. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy.
Une copie de cet exemple est disponible dans le fichier boot_simple.py présent dans le répertoire esp8266/boot/ du dépôt GitHub de l’ouvrage.
01: WIFI_SSID = "MY_WIFI_SSID"
02: WIFI_PASSWORD = "MY_PASSWORD"
03:
04: def sta_connect():
05: import network
06: wlan = network.WLAN(network.STA_IF)
07: wlan.active(True)
08: if not wlan.isconnected():
09: # connecting to network...
10: wlan.connect( WIFI_SSID, WIFI_PASSWORD )
11:
12: while not wlan.isconnected():
13: pass
14:
15: sta_connect()
16:
17: import gc
18: #import webrepl
19: #webrepl.start()
20: gc.collect()
Lignes 1-2 : permettent de définir l’identifiant du réseau Wi-Fi et le mot de passe de ce dernier.
Ligne 4 : définit la fonction sta_connect() configurant l’interface Wi-Fi en mode station.
Ligne 15 : appel/exécution de la fonction de connexion.
Ligne 17 : import du module « gb » (Garbage Collector = ramasse-miettes).
Lignes 18-19 : module et démon WebREPL (pas activé étant donné que les lignes sont en commentaires).
Ligne 20 : nettoyage du ramasse-miettes (collecte des références non utilisées et libération de la mémoire disponible).
La fonction sta_connect() permet de connecter le module ESP8266 sur le réseau Wi-Fi.
Lignes 5-7 : importation du module réseau, création d’un objet WLAN en mode station. Activation de l’interface Wi-Fi en mode station (si cela est nécessaire).
Ligne 8 : pour rappel, l’ESP8266 se reconnecte automatiquement sur le dernier réseau Wi-Fi connu par le module. Cette ligne vérifie que le module ESP8266 n’est pas reconnecté sur un réseau Wi-Fi avant de lancer la procédure de connexion.
Ligne 10 : connexion sur le réseau Wi-Fi en utilisant les paramètres SSID (identification du réseau Wi-Fi) et mot de passe Wi-Fi déclarés en début de module.
Lignes 12-13 : boucle d’attente de la connexion.
Bien que ce script soit l’exemple le plus souvent mentionné, ce dernier reste trop optimiste. Il part du principe que les informations de connexions (SSID et mot de passe) sont correctes et que le réseau Wi-Fi est disponible.
Si l’une de ces informations est incorrecte ou si le réseau n’est pas/plus disponible, le script boot.py restera bloqué dans la boucle infinie des lignes 12 à 13.
while not wlan.isconnected():
pass
En conséquence, les outils avancés tels que RShell (et probablement Ampy) ne fonctionneront plus comme attendu. Lors de l’envoi de commandes via le port série, RShell effectue une réinitialisation logicielle (équivalent du [Ctrl] D) avant de débuter une session REPL. Étant donné que le fichier boot.py entrera dans une boucle infinie, cela bloquera le fonctionnement de RShell (RShell retournera un message d’erreur après un timeout d’une dizaine de secondes).
La seule option permettant de rétablir le fonctionnement de l’ESP8266 est la réinitialisation du module en reflashant le firmware MicroPython.
En solution intermédiaire, il est possible de remplacer la boucle d’attente infinie des lignes 12-13 par une boucle d’attente avec timeout de 40 secondes comme celle-ci dessous.
import time
ctime = time.time()
while not wlan.isconnected():
if (time.time()-ctime > 40 ) :
print( ’timeout reached !’)
break;
time.sleep(0.5)
Il sera alors possible d’utiliser picocom (déjà présenté dans ce chapitre) pour établir une session REPL via la connexion série. Chaque réinitialisation ([Ctrl] D) exécutera le fichier boot.py qui rendra la main au système au bout de 40 secondes en cas de problème. Si ce délai reste trop important pour RShell, cela permet au moins d’utiliser picocom pour désactiver le fichier boot.py en le renommant dead.py à l’aide des commandes suivantes saisies dans la session REPL obtenue au terme des 40 secondes de timeout.
import os
os.rename( ’boot.py’, ’dead.py’ )
En cas de perte de contrôle, il est parfois utile d’abréger/désactiver le fonctionnement de boot.py et de main.py à partir d’une configuration matérielle.
En plaçant un petit interrupteur (ou cavalier) sur la broche 12 configurée en entrée, il devient possible de désactiver ou altérer le fonctionnement de boot.py (ainsi que de main.py). Cette approche est très pratique pour reprendre la main sur une application en perte de contrôle. En plaçant l’interrupteur sur RunApp = 0 et en redémarrant l’ESP8266, le module redémarre sans bloquer la connexion Wi-Fi et sans démarrer le script principal main.py.
Dans le montage ci-dessous, la broche 12 du Feather ESP8266 est branchée à la masse (GND) par l’intermédiaire d’un interrupteur.
Utilisation d’un interrupteur/cavalier RunApp
La lecture de l’état de l’entrée se fait comme suit :
01: from machine import Pin
02: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
03: if runapp.value() == 0:
04: print( ’Arret application. RunApp=0’ )
05: else:
06: print( ’Executer application. RunApp=1’ )
Ligne 1 : charge la classe Pin depuis le module machine. La classe Pin permet d’interagir avec les broches de l’ESP8266.
Ligne 2 : création de runapp, un objet de type Pin, permettant de lire la valeur de la broche n° 12. Le paramètre Pin.IN indique que la broche doit être initialisée en entrée et le paramètre optionnel Pin.PULL_UP active la résistance pull interne sur la broche n° 12).
Ligne 3 : l’instruction runapp.value() permet de lire la valeur de l’entrée numérique. La valeur retournée sera égale à 1 (équivalent de True) si l’entrée est au niveau haut (3,3 V). La valeur 0 (équivalent de False) sera retournée en cas de niveau logique bas (0 V ou la masse).
Une résistance pull-up est utilisée pour ramener le potentiel d’une broche au niveau logique haut (3,3 V) à moins qu’une action extérieure ne force le potentiel à un autre niveau logique. Par exemple, ramener le potentiel de la broche au niveau bas par l’intermédiaire d’un bouton poussoir.
Représentation de la résistance pull-up interne
Compte tenu des points abordés ci-dessus, cette section propose un fichier boot.py offrant les fonctionnalités suivantes :
•.connexion en mode station
•.timeout de connexion Wi-Fi
•.désactivation du point d’accès (AP)
•.RunApp : permettant d’écourter le temps de connexion sur le réseau Wi-Fi
Une copie de cet exemple est disponible dans le fichier boot.py présent dans le répertoire esp8266/boot/ du dépôt GitHub de l’ouvrage.
01: WIFI_SSID = "MY_WIFI_SSID"
02: WIFI_PASSWORD = "MY_PASSWORD"
03:
04: def sta_connect( timeout = None, disable_ap = False ):
05: import network , time
06: from machine import Pin
07: wlan = network.WLAN(network.STA_IF)
08: wlan.active(True)
09: if not wlan.isconnected():
10: wlan.connect( WIFI_SSID, WIFI_PASSWORD )
11:
12: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
13: if runapp.value() == 0:
14: print( "WLAN: no wait!")
15: else:
16: ctime = time.time()
17: while not wlan.isconnected():
18: if timeout and ((time.time()-ctime)>timeout):
19: print( "WLAN: timeout!" )
20: break
21: else:
22: time.sleep( 0.500 )
23:
24: if wlan.isconnected() and disable_ap:
25: ap = network.WLAN(network.AP_IF)
26: if ap.active():
27: ap.active(False)
28: print( "AP disabled!" )
29:
30: return wlan.isconnected()
31:
32: if sta_connect( disable_ap=True, timeout=40 ):
33: print( "WLAN: connected!" )
34:
35: import gc
36: #import webrepl
37: #webrepl.start()
38: gc.collect()
Lignes 1, 2 et 35 à 38 : reprennent des éléments déjà connus.
Ligne 32 : la fonction de connexion sta_connect() connecte l’ESP8266 en mode station sur le réseau Wi-Fi. disable_ap=True demande l’arrêt du point d’accès après la connexion sur le réseau Wi-Fi. timeout=40 limite le temps d’attente de connexion sur le réseau Wi-Fi à 40 secondes. Une valeur timeout=None attendra indéfiniment.
La fonction sta_connect() prend en charge la logique de connexion sur le réseau Wi-Fi :
Lignes 5-8 : importation des modules nécessaires, création de l’objet wlan en mode station et activation de l’interface WLAN (si elle n’est pas encore active).
Ligne 9 : si le module ESP8266 ne s’est pas automatiquement reconnecté sur le dernier réseau Wi-Fi connu, alors le script procède à l’établissement de la connexion.
Ligne 10 : connexion sur le réseau Wi-Fi identifié par le SSID et mot de passe mentionnés en lignes 01 et 02.
Lignes 12-14 : configuration de la broche 12 en entrée et lecture de l’état de la broche.
•.Si la valeur = 0 : Niveau bas correspondant à runapp = False : il faut abroger le temps d’attente de connexion Wi-Fi. Voir la ligne 14. L’exécution du script se poursuit à la ligne 24.
•.Si la valeur = 1 : Niveau haut correspondant à runapp = True : le fonctionnement est normal il faut attendre la connexion Wi-Fi (ligne 16 à 22) suivant la valeur du paramètre timeout.
Ligne 16 : capture l’heure de début, ce qui permet de suivre l’écoulement du temps durant la boucle d’attente de connexion sur le réseau Wi-Fi.
Ligne 17 : boucle d’attente de connexion Wi-Fi
Ligne 18 : le test if timeout and ... permet de tester si timeout dispose d’une valeur. Si None est assigné à timeout, alors le résultat du test if timeout est faux ! Auquel cas un temps d’attente sleep(0.500) est systématiquement exécuté. Dans le cas du timeout à None les lignes 17 à 22 se comportent comme une boucle d’attente infinie. Si timeout dispose d’une valeur numérique, alors le if timeout est vrai et le restant du test (time.time()-ctime)>timeout vérifie que le temps écoulé depuis ctime n’est pas supérieur au timeout défini (40 secondes). Si le temps écoulé dépasse le timeout, l’instruction break de la ligne 20 interrompt la boucle while et poursuit l’exécution à la ligne 24.
Lignes 24-28 : obtention d’une référence vers l’interface point d’accès (AP_IF) et désactivation du point d’accès si l’interface en mode station est connectée.
Ligne 30 : retourne l’état de la connexion de l’interface en mode station.
Cette section du chapitre va passer en revue les éléments pratiques facilitant la prise en charge et le développement de projets MicroPython sur ESP8266.
Les points suivants seront abordés :
•.création de bibliothèques
•.revue des bibliothèques standard
•.chargement d’un script en REPL, RunApp et main.py
•.montages fondamentaux autour des entrées/sorties
•.montage et utilisation de quelques senseurs
Une bibliothèque regroupe un ensemble de fonctions, de définitions ou de classes dans un fichier séparé. Tous ces éléments sont rassemblés en suivant une logique dictée soit par un support matériel (cartes ADS11x5), soit par une fonctionnalité commune (MQTT), soit par des services communs (os : interaction avec le système d’exploitation).
La création d’une bibliothèque permet d’isoler des éléments réutilisables pouvant également être exploités dans d’autres projets. L’utilisation de bibliothèques permet aussi d’alléger le contenu des différents scripts Python, ce qui améliore la lecture et la maintenance du code.
Enfin, une bibliothèque peut être vue comme une boîte noire que l’on sait utiliser pour obtenir tel ou tel résultat sans pour autant en connaître les rouages internes.
Attention à ne pas confondre bibliothèque Python et module Python. Une bibliothèque est un fichier Python contenant du code. Un module est un sous-répertoire contenant généralement plusieurs fichiers Python contenant eux-mêmes des classes, des fonctions et des déclarations diverses.
La suite de cette section propose de créer une bibliothèque nommée « outil » qui sera ensuite utilisée pour démontrer l’usage de l’instruction Python import.
Créer un fichier outil.py contenant le code ci-dessous. Le fichier peut être créé depuis RShell en utilisant les commandes suivantes :
!touch outil.py
!nano outil.py
Contenu du fichier outil.py :
def create_list( count, init_value ):
l = []
for i in range( count ):
l.append( init_value )
return l
def add( v1, v2 ):
return v1 + v2
COULEUR_ROSE = (255, 153, 204)
Le fichier outil.py peut être envoyé sur l’ESP8266 à l’aide de RShell avec la commande cp. Par la suite, la commande ls permet de vérifier que le fichier est bien présent dans le répertoire racine de la carte ESP8266.
Copie de la bibliothèque outil
Une session REPL permet de tester la bibliothèque sur la plateforme MicroPython à l’aide de quelques instructions :
01: >>> from outil import *
02: >>> print( COULEUR_ROSE )
03: (255, 153, 204)
04: >>> print( add(125,15) )
05: 140
06: >>> lst = create_list( 4, 0 )
07: >>> print( lst )
08: [0, 0, 0, 0]
09: >>> lst2 = create_list( 3, COULEUR_ROSE )
10: >>> print( lst2 )
11: [(255, 153, 204), (255, 153, 204), (255, 153, 204)]
Ligne 1 : importation de tous les éléments de la bibliothèque outil dans l’espace de noms courant. Le fichier outil.py est ouvert et chargé par l’interpréteur MicroPython.
Ligne 2 : affichage de la valeur de la constante COULEUR_ROSE définie dans la bibliothèque. Les constantes sont définies en majuscule.
Ligne 4 : appel de la fonction add() définie dans la bibliothèque outil.
Lignes 6-10 : appel de la fonction create_list() définie dans la bibliothèque.
À noter qu’il existe différentes syntaxes utilisables pour importer une bibliothèque. Celles-ci sont passées en revue ci-dessous.
La bibliothèque peut être importée en utilisant un espace de noms différent qu’il faudra alors préciser lors de l’appel des différents éléments. Dans l’exemple suivant, la bibliothèque outil est importée sous l’espace de noms « tls ».
01: >>> import outil as tls
02: >>> print( tls.COULEUR_ROSE )
03: (255, 153, 204)
04: >>> print( tls.add(125,15) )
05: 140
06: >>> lst = tls.create_list( 4, 0 )
07: >>> print( lst )
08: [0, 0, 0, 0]
09: >>> lst2 = tls.create_list( 3, tls.COULEUR_ROSE )
10: >>> print( lst2 )
11: [(255, 153, 204), (255, 153, 204), (255, 153, 204)]
À noter qu’il est également possible d’importer le module sans préciser d’espace de noms (auquel cas, il faudra utiliser le nom du module comme espace de noms).
01: >>> import outil # sans espace de noms
02: >>> print( outil.COULEUR_ROSE )
03: (255, 153, 204)
04: >>> print( outil.add(125,15) )
05: 140
06: >>> lst = outil.create_list( 4, 0 )
07: >>> print( lst )
08: [0, 0, 0, 0]
09: >>> lst2 = outil.create_list( 3, outil.COULEUR_ROSE )
10: >>> print( lst2 )
11: [(255, 153, 204), (255, 153, 204), (255, 153, 204)]
L’instruction import peut restreindre les éléments importés depuis la bibliothèque en nommant ces derniers dans la commande. Dans l’exemple suivant, la fonction add() et la constante COULEUR_ROSE sont importées. L’exécution de la commande create_list(), non importée, produit donc une erreur d’exécution.
>>> from outil import add, COULEUR_ROSE
>>> add( COULEUR_ROSE, COULEUR_ROSE )
(255, 153, 204, 255, 153, 204)
>>> create_list( 3, COULEUR_ROSE )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name ’create_list’ is not defined
>>>
MicroPython embarque un sous-ensemble de bibliothèques Python, des bibliothèques spécifiques à MicroPython ainsi qu’une bibliothèque propre au module ESP8266.
Les bibliothèques MicroPython n’implémentent qu’une partie des fonctions des bibliothèques standard Python et suivant les ressources disponibles sur la plateforme MicroPython cible, certaines bibliothèques standard pourraient ne pas être incluses dans le firmware MicroPython.
La documentation des différentes bibliothèques est disponible en ligne sur le lien suivant : https://docs.micropython.org/en/latest/esp8266/library/index.html
Dans la liste suivante, certaines bibliothèques standards sont préfixées par un « u » (ex. : uos, uzlib...).
Le chargement d’une bibliothèque se fait sans préciser le « u ». Par exemple, uzlib s’importe avec la commande import zlib. Ce préfixe est utilisé par MicroPython pour altérer le processus de chargement d’une bibliothèque, point qui sera abordé plus loin dans le chapitre.
•.Array : gestion des tableaux de données numériques.
•.gc : gestion du ramasse-miettes (garbage collector).
•.math : fonctions mathématiques.
•.sys : fonctions relatives au système.
•.ubinascii : conversion binaire/ASCII.
•.ucollections : différents types de collections et dictionnaires.
•.uerrno : codes d’erreur système.
•.uhashlib : algorithme de hashing (sha256, sha1, md5).
•.uheapq : algorithme de gestion de pile (liste où les éléments sont stockés d’une façon particulière).
•.uio : flux d’entrée/sortie. La fonction standard open() d’ouverture de fichier fait appel à différents éléments de cette bibliothèque.
•.ujson : encodage et décodage au format JSON.
•.uos : utilitaires de gestion du système de fichier (lister, effacer, renommer des fichiers, gestion des répertoires, etc.).
•.ure : traitement des expressions régulières (regularexpressions).
•.uselect : attente d’événement sur un ensemble de flux (stream). Permet de surveiller plusieurs flux (connexions) en même temps jusqu’à ce que l’un d’entre eux devienne disponible en lecture, écriture (ou retourne une erreur).
•.usocket : module de prise en charge des sockets (connexion Ethernet).
•.ustruct : conversion de valeurs Python et structures C (structures représentées sous forme d’objets Python de type « bytes »).
•.ussl : module d’encryption SSL/TLS. Ce module doit absolument être importé en tant que ussl sur un ESP8266.
•.utime : fonction de gestion du temps (dates et heures), de mesure d’intervalles et de gestion de délais.
•.uzlib : décompression zlib.
Le site documentaire de MicroPython rapporte que certaines implémentations de ussl ne valident pas le certificat serveur. Ce qui ne protège pas les connexions SSL contre les attaques « Man-In-The-Middle ».
Les fonctionnalités spécifiques de la plateforme MicroPython sont reprises dans les bibliothèques suivantes :
•.btree : module permettant de créer une mini base de données Btree.
•.framebuf : manipulation de Frame Buffer, structure permettant de créer des images bitmap pouvant ensuite être envoyées sur un afficheur.
•.machine : fonctions relatives à la plateforme matérielle sur laquelle fonctionne MicroPython. Concerne la prise en charge d’interruptions, la réinitialisation (reset), la mise en veille, l’accès aux différentes classes relatives au support matériel de la plateforme (Pin, UART, SPI, I2C, Signal, RTC, Timer, WDT).
•.micropython : accès et contrôle sur des éléments internes de MicroPython (pile, tas, mémoire libre, etc.).
•.network : configuration d’interfaces réseau et statut de la connexion.
•.uctypes : se présente comme une alternative à la bibliothèque standard ustruct pour la conversion de structure entre C et Python. Le format de la structure est décrit à l’aide d’un « descripteur » encodé dans un dictionnaire Python. ctypes se présente comme une alternative mieux adaptée que ustruct pour le traitement des structures plus grandes et plus complexes.
esp est une bibliothèque contenant des fonctions spécifiques à l’ESP8266. Type de veille, mode hibernation, lecture et écriture de la mémoire Flash.
Le projet micropython-lib disponible sur GitHub (https://github.com/micropython/micropython-lib) développe une série de bibliothèques standard pour MicroPython. Ce projet GitHub cible MicroPython sur plateforme Unix. Cependant, les bibliothèques n’impliquant aucun échange d’entrée/sortie pourraient également fonctionner sans problème avec les versions embarquées de MicroPython.
La section précédente met en lumière la présence d’un préfixe « u » sur certaines bibliothèques standards. Ce « u » correspond au signe micro « µ » indiquant que la bibliothèque a été « MicroPythonifiée ». Bien que conçue comme un remplacement du module Python équivalent, une bibliothèque MicroPythonifiée n’implémente que la partie essentielle des fonctionnalités de la bibliothèque Python afin de répondre à la philosophie et aux limitations matérielles (RAM, mémoire Flash) d’une plateforme MicroPython.
Il peut être gênant de modifier les noms des modules Python dans vos scripts développés sur PC ou d’écrire une surcouche chargeant une bibliothèque MicroPythonifiée en lieu et place du nom de bibliothèque standard. Pour répondre à cette problématique, le firmware MicroPython adapte le mécanisme de chargement d’une bibliothèque Python (par exemple import socket) comme suit :
1. | MicroPython recherche la bibliothèque socket dans le système de fichier. |
2. | Si la bibliothèque socket existe, alors cette dernière est chargée par le firmware. |
3. | Si la bibliothèque n’est pas localisée alors MicroPython recherche la bibliothèque MicroPythonifiée, donc usocket, dans le firmware. |
4. | Si la bibliothèque MicroPythonifiée existe dans le firmware, alors cette dernière est chargée. Dans le cas contraire, une exception ImportError est levée. |
Ce mécanisme de chargement permet d’étendre les fonctionnalités d’une bibliothèque MicroPythonifiée (uXXX) en écrivant une bibliothèque (XXX.py) stockée dans le système de fichiers. Ce mécanisme permet également de placer sur le système de fichiers une bibliothèque issue de la micropython-lib qui ne serait pas compilée dans firmware MicroPython (note : il faudra enlever le préfixe « u »).
Le script blinky.py ci-dessous est utilisé pour démontrer la fonctionnalité de chargement à la volée. En guise de programme utilisateur, le script fait clignoter la LED rouge attachée sur la broche 0 du Feather Huzzah ESP8266.
Une copie de cet exemple est disponible dans le fichier blinky.py présent dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage.
01: from machine import Pin
02: from time import sleep
03:
04: # Led rouge sur la carte
05: led = Pin( 0, Pin.OUT )
06: # Eteindre LED (logique inversée)
07: led.value( 1 )
08:
09: def blink( count = 3 ):
10: # clignoter = changer 2x d état
11: for i in range( count * 2 ):
12: # changer etat de l entrée
13: led.value( 0 if led.value()==1 else 1 )
14: sleep( 1 )
15: # Eteindre la LED
16: led.value( 1 )
17:
18: # exécution (pour un temps limité)
19: print( ’Blink x 10’ )
20: blink( 10 )
21: print( ’C est fait’ )
Lignes 5-7 : initialise la broche associée à la LED rouge du Feather ESP8266 Huzzah et maintient une référence vers l’objet Pin dans la variable led. Fait en sorte que la LED reste éteinte. Compte tenu du montage de cette LED sur la carte, cette dernière est éteinte lorsque la broche est au niveau haut et allumée lorsque la broche est au niveau bas.
Ligne 9 : déclaration de la fonction blink() faisant clignoter la LED le nombre de fois mentionné dans le paramètre count.
Ligne 11 : allumer et éteindre la LED nécessite deux opérations d’inversion d’état de la broche associée à la LED. La boucle inversera count x 2 fois l’état de la LED.
Ligne 13 : utilise l’évaluation ternaire 0 if led.value()==1 else 1 pour déterminer l’état inverse par rapport à l’état actuel de la LED. L’expression retourne 0 si led.value() est évalué à 1 sinon l’expression retourne 1. La valeur retournée est donc l’inverse de l’état actuel de la broche. L’instruction dans son ensemble inverse l’état de la broche raccordée à la LED.
Ligne 15 : pause de 1 seconde entre chaque changement d’état.
Ligne 16 : assure que la LED est éteinte en sortie de boucle.
Lignes 19-21 : section de code (de 3 lignes dans le cas présent) exécutée au chargement du fichier blinky.py. Affiche également des messages qui seront visibles dans une session REPL.
Il est maintenant possible de tester l’exécution à la volée après avoir transféré le script blinky.py sur la carte ESP8266.
Ouvrez une session REPL et saisissez les instructions suivantes :
import blinky
Ce qui produit le résultat suivant :
Résultat de l’importation du fichier blinky
Le résultat ci-dessus démontre clairement l’exécution des lignes 19 à 21 présentes dans le fichier importé.
Voici une méthode intéressante permettant de tester, à la volée, un script en cours de développement sans avoir à saisir une ou plusieurs lignes de code pour initialiser les objets nécessaires à la conduite d’un test (code que l’on retrouve typiquement dans main.py).
Cette opportunité d’importer et exécuter un script à la volée, qui n’est autre qu’une fonctionnalité normale de Python, présente un autre intérêt développé dans la section ci-dessous.
Le script blinky, comme n’importe quel autre script, peut être réimporté et réexécuté dans la session REPL en réinitialisant l’interpréteur MicroPython à l’aide de la combinaison de touches [Ctrl] D.
La section Un fichier boot.py pour ESP8266 (cf. Séquence de démarrage MicroPython dans ce chapitre) introduisait le concept de RunApp permettant de reprendre la main sur le système MicroPython lorsque l’exécution ne se déroule pas comme prévu dans boot.py.
Cette même technique peut également être réutilisée pour l’exécution du script principal. Voici de nouveau le montage RunApp où la broche 12 du Feather ESP8266 est branchée sur la masse (GND) par l’intermédiaire d’un interrupteur.
Utilisation d’un interrupteur/cavalier RunApp
À l’instar du fichier boot.py déjà proposé dans cet ouvrage, le fichier main.py peut également lire l’entrée de la broche 12 pour autoriser (ou non) l’exécution du programme principal.
Dans l’exemple ci-dessous, le fichier main.py se propose d’exécuter, à la volée, le script blinky si l’interrupteur RunApp est en position exécution.
01: from machine import Pin
02: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
03: if runapp.value() == 0:
04: print( ’Arret application. RunApp=0’ )
05: else:
06: import blinky
Une copie de cet exemple est disponible dans le fichier blinky_main.py présent dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Fichier à renommer main.py lorsqu’il est copié dans le répertoire racine de l’ESP8266.
La technique RunApp permet de neutraliser l’exécution des scripts (boot.py et main.py) en cas de problème bloquant imprévu.
Placer l’interrupteur sur la position RunApp=0 et presser le bouton « Reset » permet d’instantanément reprendre le contrôle de la plateforme.
Il est bien entendu possible de remplacer le contenu de la section « else » par diverses instructions ou l’appel d’une fonction exécutant le corps du programme souhaité en lieu et place d’une importation.
Cette section reprend de nombreux schémas et graphiques. Ceux-ci peuvent être consultés en couleurs sur la version en ligne de l’ouvrage.
Une entrée numérique permet de lire l’état logique d’un signal produit par un périphérique externe (microcontrôleur, senseur, bouton, interrupteur, etc.).
Une broche configurée en entrée présente une haute impédance (un peu comme une « haute résistance ») qui fait obstacle au passage du courant. Une broche en entrée peut donc être branchée directement sur une source de tension, dans les limites de la tolérance de l’entrée, sans risquer de provoquer un court-circuit.
Une entrée numérique d’un ESP8266 est capable de différencier :
•.Un niveau haut : lorsque la tension appliquée sur la broche est située entre 2,475 V et 3,6 V. Raccorder l’entrée sur la broche 3,3 V produira un niveau haut.
•.Un niveau bas : lorsque la tension appliquée sur la broche est située entre -0,3 V et 0,825 V. Raccorder l’entrée sur la masse (GND) produira un niveau bas.
La région située entre 0,825 V et 2,475 V ne permet pas la détermination du niveau logique d’une entrée. Dans cette région, la lecture de l’entrée numérique produira une valeur logique aléatoire (haut ou bas).
Le montage ci-dessous reprend le raccordement d’un bouton poussoir avec une résistance de rappel à +3,3 V, dite « résistance pull-up ».
Utilisation d’une entrée numérique (avec résistance pull-up externe)
Lorsque le bouton est pressé, son contact fermé connecte la broche 13 directement à la masse (broche GND, donc 0 V). La broche 13 étant à 0 V, le niveau logique de l’entrée est donc « bas ».
Lorsque le bouton est relâché, son contact ouvert libère le potentiel de la broche 13 qui n’est plus raccordée à la masse. La résistance pull-up joue alors son rôle et fixe le potentiel à 3,3 V. La broche 13 est donc au niveau haut.
À noter que lorsque le bouton est pressé, le régulateur de tension est branché à la masse par l’intermédiaire d’une résistance de 10 Kohms. Cela permet d’éviter une situation de court-circuit franc. La résistance limite le courant (entre l’alimentation et la masse) à 3,3 / 10000 = 0,00033 A, soit 0,33 mA.
Les lignes de commandes ci-dessous, saisies dans une session REPL, permettent de lire et d’afficher l’état du bouton.
01: >>> from machine import Pin
02: >>> from time import sleep
03: >>> p = Pin( 13, Pin.IN )
04: >>> while True:
05: ... if p.value() == 1:
06: ... print(’PAS presse’)
07: ... else:
08: ... print(’BOUTON PRESSE’)
09: ... sleep( 1 )
Ce qui produit le résultat suivant :
PAS presse
PAS presse
BOUTON PRESSE
BOUTON PRESSE
BOUTON PRESSE
PAS presse
Traceback (most recent call last):
File "<stdin>", line 6, in <module>
KeyboardInterrupt:
Presser [Ctrl] C pour interrompre le fonctionnement du script dans la session REPL.
Ligne 1 : importe la classe Pin permettant de manipuler les broches de l’ESP8266.
Ligne 3 : création d’un objet Pin associé à la broche 13. La constante Pin.IN indique que la broche est configurée en entrée (en anglais, IN signifie « entrée »).
Ligne 5 : p.value() permet de lire l’état de la broche et retourne une valeur numérique. La valeur 1 correspond au niveau haut tandis que la valeur 0 correspond au niveau bas. À noter que 1 et 0 sont respectivement évalués aux valeurs logiques True et False. L’utilisation de l’instruction « if p.value() : » aurait donc produit le même résultat.
Ligne 6 : suivant le montage réalisé, le bouton relâché implique un potentiel de 3,3 V sur l’entrée. Cela se traduit par la valeur 1 retournée lors de l’appel de p.value().
La plupart des microcontrôleurs disposent d’une résistance pull-up interne et parfois aussi d’une résistance pull-down. Cette résistance interne est activable à la demande.
L’activation de la résistance pull-up de l’ESP8266 permet de simplifier le montage d’une entrée numérique sans modifier la logique de fonctionnement présentée ci-dessus.
Dans le montage ci-dessous, la résistance pull-up maintiendra la broche au niveau haut (3,3 V) à moins que le potentiel ne soit forcé au niveau bas (la masse, GND) à l’aide du bouton poussoir.
Utilisation d’une entrée numérique (avec résistance pull-up interne)
Exemple de code :
01: >>> from machine import Pin
02: >>> from time import sleep
03: >>> p = Pin( 13, Pin.IN, Pin.PULL_UP )
04: >>> while True:
05: ... print( "---" if p.value() else "BOUTON PRESSE" )
06: ... sleep( 1 )
Ce qui produit le résultat suivant :
---
---
---
BOUTON PRESSE
BOUTON PRESSE
---
BOUTON PRESSE
---
---
---
Ligne 3 : lors de la création de l’instance de la classe Pin pour la broche 13, le deuxième paramètre Pin.IN configure la broche en entrée tandis que le troisième paramètre Pin.PULL_UP active la résistance pull-up interne sur la broche.
Ligne 5 : évaluation d’une expression ternaire. Comme déjà précisé, p.value() est évalué à False si la fonction retourne 0 et True dans le cas contraire. L’expression ternaire retourne « --- » si p.value() renvoie 1 (bouton non pressé), sinon la chaîne « BOUTON PRESSE » sera retournée par l’expression ternaire.
Lorsque l’on presse le bouton poussoir, le signal ne passe pas instantanément du niveau haut au niveau bas. Le signal présente une phase transitoire pendant laquelle celui-ci passe plusieurs fois d’un niveau logique à l’autre.
Transition du signal d’un niveau logique à l’autre
L’effet transitoire ne durant qu’un très bref instant, de l’ordre de la milliseconde, il peut fortement perturber le fonctionnement d’un programme. Les microcontrôleurs sont tellement rapides qu’il est possible de lire plusieurs fois l’entrée durant ce laps de temps.
L’exemple suivant illustre l’impact néfaste de l’effet transitoire sur un simple programme de comptage.
Le script suivant détecte le flanc descendant sur la broche 13 (passage de niveau haut au niveau bas lors de la pression du bouton) et incrémente la valeur du compteur. Il arrive parfois, à cause de l’effet transitoire, que le compteur soit incrémenté plusieurs fois pour une seule pression du bouton. Dans pareil cas de figure, le script détecte plusieurs flancs descendants et flancs montants durant l’effet transitoire.
01: from machine import Pin
02: compteur = 0
03: p = Pin( 13, Pin.IN, Pin.PULL_UP )
04: dernier_etat = p.value()
05: while True:
06: etat = p.value()
07: if (etat == 0) and (dernier_etat==1):
08: dernier_etat = etat
09: compteur += 1
10: print( compteur )
11: if (etat == 1) and (dernier_etat==0):
12: dernier_etat = etat
Lignes 1-3 : import des éléments nécessaires, initialisation du compteur et de la broche 13 en entrée (avec pull-up).
Ligne 4 : initialisation du dernier état connu de la broche d’entrée.
Ligne 5 : boucle infinie (presser [Ctrl] C pour arrêter le script dans une session REPL).
Ligne 6 : capture de l’état de la broche d’entrée.
Ligne 7 : détection de la transition vers le niveau bas. Si l’ancien état connu est niveau haut (1) et état actuel de la broche indique un niveau bas (0), alors le bouton vient d’être pressé.
Ligne 8 : capture du nouvel état (niveau bas) comme dernier état connu afin d’éviter la réactivation de la condition en ligne 7.
Lignes 9-10 : incrémentation de la valeur du compteur et affichage de cette valeur.
Ligne 11 : détection du flan montant (passer d’un niveau bas au niveau haut) lorsque le bouton est relâché.
Ligne 12 : rien à exécuter sur le flan montant donc la seule opération consiste à capturer le dernier état connu.
L’exemple ci-dessus est surtout didactique. Il est bien évidemment possible de rendre le script beaucoup plus concis.
Il existe deux approches différentes permettant d’éliminer le phénomène de rebonds :
•.par l’utilisation de composants électroniques tenant compte du phénomène de rebond,
•.par l’utilisation d’une astucieuse technique de programmation permettant de contourner le problème du rebond.
Le problème du rebond peut être contourné avec la technique de programmation suivante :
1. | Lire l’entrée + détection du changement d’état. |
2. | Attendre 10 ms. Temps largement supérieur à la période transitoire, mais bien en dessous du temps nécessaire à un humain pour relâcher le bouton. |
3. | Relire l’entrée et s’assurer qu’elle est toujours dans le nouvel état. Si l’état n’est pas resté identique, il s’agit d’un effet transitoire ou d’un parasite. Dans pareil cas, il convient d’ignorer le changement d’état. |
Le programme ci-dessous met en œuvre la technique de déparasitage logiciel.
01: from machine import Pin
02: from time import sleep
03: compteur = 0
04: p = Pin( 13, Pin.IN, Pin.PULL_UP )
05: dernier_etat = p.value()
06: while True:
07: etat = p.value()
08: if etat != dernier_etat:
09: sleep( 0.010 )
10: if p.value() != etat:
11: continue
12:
13: if (etat == 0) and (dernier_etat==1):
14: dernier_etat = etat
15: compteur += 1
16: print( compteur )
17: if (etat == 1) and (dernier_etat==0):
18: dernier_etat = etat
Lignes 1-2 : import des classes et fonctions nécessaires.
Lignes 3-5 : initialisation des variables (dont le dernier état connu de la broche d’entrée).
Ligne 6 : boucle infinie.
Ligne 7 : lecture de l’état de la broche d’entrée.
Ligne 8 : si l’état est différent du dernier état connu, procéder au déparasitage par traitement logiciel.
Ligne 9 : attendre 10 millisecondes avant de relire l’entrée.
Ligne 10 : si la relecture de l’entrée est différente de l’état lu 10 ms plus tôt.
Ligne 11 : alors reprendre l’exécution de la boucle (ligne 06).
Lignes 13-18 : à ce stade, le déparasitage logiciel permet d’être certain du changement d’état de l’entrée. Le restant du script reprend la détection du flan descendant pour effectuer le comptage.
Une broche peut également être configurée pour être utilisée comme broche de sortie. Cela permet au microcontrôleur de fixer l’état logique de la broche (niveau haut/niveau bas) afin de piloter d’autres périphériques (relais, LED, etc.).
Dans l’exemple ci-dessous, la broche 15 est utilisée pour contrôler une LED.
Utiliser une sortie numérique
Une broche en sortie voit son potentiel fixé à 3,3 V lorsqu’elle est placée au niveau haut par une instruction. Le potentiel de la broche est de 0 V (ramenée à la masse) lorsque cette dernière est placée au niveau bas. Pour rappel, le courant maximum par broche est de 12 mA. Au-delà, la sortie et/ou le microcontrôleur risquent d’être détruits.
Dans le cas du montage ci-dessus, la broche 15 fournit le courant nécessaire au fonctionnement de la LED. Une LED rouge, jaune ou orange de préférence.
Les LED bleues et certaines LED vertes ayant une tension de fonctionnement trop proche de 3,0 V (forward voltage). Il est difficile de les utiliser sur des systèmes en logique 3,3 V.
La sortie 13 est branchée sur la broche la « plus longue » d’une LED (celle correspondant au pôle positif (plus) par l’intermédiaire d’une résistance de 1 Kohms. La résistance limite le courant traversant la LED lorsque la broche est au niveau haut.
Voici quelques commandes permettant de prendre le contrôle de la LED.
01: >>> from machine import Pin
02: >>> p2 = Pin( 15, Pin.OUT )
03: >>> p2.value( 1 )
04: >>> p2.value( 0 )
Ligne 1 : import de la classe Pin permettant de commander une broche.
Ligne 2 : créer une instance de classe Pin pour la broche 15. Le paramètre Pin.OUT indique que la broche sera configurée en sortie.
Ligne 3 : p2.value( 1 ) place la broche au niveau haut. Le potentiel de la broche 15 est donc de 3,3 V, ce qui permet au courant de traverser la LED et de l’allumer.
Ligne 4 : place la broche au niveau bas. Il n’y a donc aucun courant traversant la LED qui reste éteinte.
Comme déjà précisé, l’unique entrée analogique de l’ESP8266 accepte une tension maximale de 1,0 volt. Dans l’exemple suivant, un potentiomètre de 10 Kohms est utilisé conjointement à une résistance de 26,7 Kohms pour réaliser un pont diviseur de tension produisant une tension de 0 à 0,899 volt.
Lecture analogique
Suivant la théorie des ponts diviseurs de tension, reprise ci-dessous, en utilisant R1 = 26,7K et R2 = 10K (valeur maximale du potentiomètre et produisant donc la tension maximale U2 en sortie du pont diviseur), U2 est la tension appliquée sur le convertisseur ADC, alors que le pont diviseur est alimenté en 3,3 V (U1).
Pont diviseur de tension
Pour mémoire, voici le développement du calcul de la tension de sortie du pont diviseur permettant de vérifier que le montage permet de rester dans la zone de tolérance de l’entrée ADC.
U2 = U1 * R2 / (R1 + R2)
U2 = 3,3V * 10000 / (10000+26700) # 26700 provient de 22K + 4,7K
U2 = 0,89918
La lecture de l’entrée analogique passe par la classe ADC (Analog to Digital Converter), une classe attachée au convertisseur analogique vers numérique.
L’entrée analogique dispose d’une résolution 10 bits, ce qui signifie que le convertisseur retourne une valeur numérique entre 0 et 1024. Valeurs correspondant respectivement à 0 et 1,0 V.
Le script suivant, saisi dans une session REPL, surveille la valeur de l’entrée analogique et affiche chaque modification de celle-ci ainsi que la tension correspondante.
01: from machine import ADC
02: from time import sleep
03: adc = ADC( 0 )
04: val = adc.read()
05: while True:
06: val2 = adc.read()
07: if not( val-3 < val2 < val+3 ):
08: print( ’Lecture: %s’ % val2 )
09: print( ’ * : %s V’ % ((1/1024)*val2) )
10: val = val2
11: sleep( 0.3 )
Ligne 1 : import de la classe ADC, convertisseur « Analogique vers Numérique ».
Ligne 3 : création de l’objet ADC en précisant le numéro de broche analogique. La broche 0 correspond à la première et unique entrée analogique de l’ESP8266.
Ligne 4 : lecture de la valeur actuelle (dernière valeur connue).
Ligne 5 : boucle infinie. Note : presser [Ctrl] C pour interrompre le script.
Ligne 6 : nouvelle lecture de l’entrée analogique (dans la variable val2).
Ligne 7 : provoque l’exécution des lignes 8 à 10 si la nouvelle valeur est différente de l’ancienne valeur connue (résultat de la modification de la position du potentiomètre). L’expression val-3 <val2 < val+3 compare la nouvelle valeur (val2) par rapport à l’intervalle ± 3 de l’ancienne valeur (val). Elle retourne True si val2 < val+3 et si val2 > val-3. Les lectures analogiques successives d’une entrée analogique peuvent sensiblement osciller autour d’une valeur centrale. Cela est provoqué par d’imperceptibles mouvements du curseur sur la résistance, la tension d’entrée à la limite entre deux valeurs du convertisseur, du bruit en provenance de l’alimentation, la variation de température, etc. C’est la raison pour laquelle le test val2 != val a été écarté car val2 serait presque systématiquement différent de val.
Ligne 8 : affichage de la valeur du convertisseur (valeur entre 0 et 1024).
Ligne 9 : conversion de la valeur numérique en tension. La tension max étant de 1 V pour une valeur retournée allant de 0 à 1024 (résolution 10 bits), chaque unité numérique à 1/1024 volt. La tension analogique est donc égale à lecture_adc * (1/1024).
Ligne 10 : capture de la valeur lue (val2) comme dernière valeur connue (val).
Ligne 11 : pause de 300 millisecondes entre deux lectures adc.
La ligne 9 utilise une double parenthèse dans l’expression ((1/1024)*val2). Si cela n’avait pas été le cas, l’instruction aurait affiché val2 fois le résultat de la division (1/2014). En effet, ’+-’*5 produit la chaîne de caractères ’+-+-+-+-+-’.
L’exécution du script produit le résultat suivant lorsque le bouton du potentiomètre passe de la position minimale à la position maximale :
Lecture: 90
* : 0.0878906 V
Lecture: 106
* : 0.103516 V
Lecture: 112
* : 0.109375 V
Lecture: 223
* : 0.217773 V
...
Lecture: 939
* : 0.916992 V
Lecture: 942
* : 0.919922 V
Lecture: 939
* : 0.916992 V
La valeur finale lue sur le convertisseur correspond à 0,916 mV. Ces 17 mV supplémentaires représentent une erreur de 1,89 % par rapport à la valeur max. attendue. Tout à fait acceptable, mais pas étonnant étant donné les 5 % de tolérance des résistances.
Ce qui manque cruellement à l’ESP8266, ce sont des entrées/sorties. Dès lors que le projet prend de l’envergure (ex. : commande de plusieurs relais), il devient rapidement nécessaire de trouver une solution.
Heureusement, il y a le MCP23017 « port expander », composant permettant d’ajouter 16 broches d’entrée/sortie sur un montage. Le MCP23017 est un composant utilisant le bus I2C.
Composant MCP23017 port expander de Microchip
Le bus I2C est un bus d’échange de données utilisant uniquement deux fils, SDA pour le signal de donnée et SCL pour le signal d’horloge. L’architecture d’un bus I2C prévoit un maître (généralement le microcontrôleur, l’ESP8266 dans le cas présent) et des esclaves (senseur I2C et MCP23017) partageant tous les mêmes signaux SDA et SCL.
Le protocole I2C prévoit un mécanisme d’adressage sur 7 bits dans le protocole de communication, ce qui permet à plusieurs esclaves de cohabiter sur le même bus pour autant que chacun d’entre eux dispose d’une adresse unique sur celui-ci.
Par ailleurs, les composants I2C disposent souvent d’une ou plusieurs broches permettant d’altérer l’adresse par défaut du composant. Par exemple, le MCP23017 dispose de 3 broches d’adresse, ce qui permet d’avoir jusqu’à 8 adresses différentes (de 0x20 à 0x27), soit un total de 128 entrées/sorties.
Cela ayant de l’importance, le bus transmet les données en faisant passer les signaux au niveau logique bas, ce qui signifie que le bus I2C doit disposer de résistance pull-up ramenant le potentiel des signaux au niveau haut. Suivant la plateforme microcontrôleur et/ou les senseurs utilisés, il est parfois nécessaire de placer ces résistances pull-up sur le montage.
Caractéristiques du MCP23017 :
•.Chaque broche peut être configurée en entrée ou en sortie.
•.Chaque entrée peut avoir une résistance pull-up activable à la demande.
•.Fonctionne avec des niveaux logiques 3,3 V et 5 V.
•.Courant maximum par broche : 25 mA (source et absorption).
•.Courant maximum pour le composant : 125 mA.
Dans le montage ci-dessous, le MCP23017 est remplacé par une représentation symbolique facilitant l’identification des différentes broches. Le renfoncement en forme de demi-lune sert de détrompeur, un point permet d’identifier la broche n°1.
L’exemple suivant propose d’utiliser :
•.GPA0 (GPIO 0, broche 21) du MCP23017 en sortie avec utilisation d’une LED et une résistance de 1 Kohms.
•.GPB1 (GPIO 9, broche 2) du MCP23017 en entrée. La résistance pull-up interne sera activée sur cette entrée. L’entrée passera au niveau bas lorsque l’interrupteur sera activé.
•.L’adresse du MCP23017 est fixée à 0x20 (adresse par défaut).
•.16 GPIO numérotés de 0 à 15 couvrant les GPA0 à 7 suivis de GPB0 à 7.
Le MCP23017 et l’ESP8266 ne disposant pas de résistances pull-up, le montage prévoit également deux résistances pull-up de 10 Kohms pour ramener le potentiel à des signaux SDA et SCL à 3,3 V.
Raccordement du MCP23017 sur un ESP8266
L’utilisation du MCP23017 requiert l’installation de la bibliothèque mcp230xx.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/mcp230xx
Seul le fichier MCP230xx.py doit être transféré dans le répertoire racine de la plateforme ESP8266.
La fonction scan() permet de détecter la présence du MCP23017 sur le bus I2C en effectuant une opération de scan. Voir l’exemple REPL ci-dessous :
>>> from machine import I2C, Pin
>>> i2c = I2C( sda=Pin(4), scl=Pin(5) )
>>> i2c.scan()
Ce qui produit le résultat suivant :
[32]
L’opération retourne une liste d’adresses. Le seul élément est l’adresse 32 en base 10, ce qui correspond à 0x20 (en notation hexadécimale).
Le code suivant explore les possibilités de manipulation des entrées/sorties sur le MCP23017.
La bibliothèque utilise une numérotation GPIO de 0 à 15.
•.GPIO 0 à 7 : les broches GPA0 à GPA7
•.GPIO 8 à 15 : les broches GPB0 à GPB7
01: >>> from machine import I2C, Pin
02: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )
03: >>>
04: >>> from mcp230xx import MCP23017
05: >>> gpios = MCP23017( i2c, address=0x20 )
06: >>> gpios.setup( 0, Pin.OUT )
07: >>> gpios.output( 0, True )
08: >>> gpios.output( 0, False )
09: >>>
10: >>> gpios.setup( 9, Pin.IN )
11: >>> gpios.pullup( 9, True )
12: >>> print( gpios.input( 9 ) )
13: True
14: >>> print( gpios.input( 9 ) )
15: False
16: >>>
Ligne 1 : import des classes nécessaires.
Ligne 2 : création d’une instance du bus I2C.
Ligne 4 : importation de la classe contrôlant un MCP23017.
Ligne 5 : création d’une instance du composant MCP23017. Passer le bus I2C en paramètre et indiquer l’adresse du composant sur le bus I2C (0x20).
Ligne 6 : configuration de la broche GPIO 0 du MCP23017 en sortie.
Ligne 7 : activation de la sortie (niveau haut, 3,3 volts). La LED s’allume.
Ligne 8 : désactivation de la sortie (niveau bas, 0 volt). La LED est éteinte.
Ligne 10 : configuration de la GPIO 9 du MCP23017 en entrée.
Ligne 11 : activation de la résistance pull-up sur le GPIO 9 du MCP23017.
Lignes 12-13 : affichage de l’état du GPIO 9 du MCP23017. L’état True indique que l’entrée est au niveau haut et donc que l’interrupteur n’est pas fermé.
Lignes 14-15 : affichage, une seconde fois, de l’état du GPIO 9. L’état False indique que l’entrée est au niveau bas et donc que l’interrupteur est fermé.
Comme déjà précisé, l’unique entrée analogique de l’ESP8266 n’est tolérante qu’à un seul volt, ce qui ne représente pas une gamme de tension confortable pour les makers.
Le convertisseur analogique/numérique ADS1115 permet d’ajouter 4 canaux analogiques (ou 2 entrées analogiques différentielles) pouvant accepter une tension d’alimentation de 2 V à 5,5 V. L’ADS1115 permettra d’effectuer des lectures analogiques jusqu’à 3,3 volts (tension logique de l’ESP8266) avec une précision 16 bits (valeur signée allant de -32768 à +32767). Attention, le signe « - » ne signifie pas pour autant que l’ADS1115 est capable de lire une tension négative !
Breakout ADS1115 (à gauche) et TMP36 senseur de température analogique (à droite)
Le convertisseur ADS1115 utilise le bus I2C pour le transfert des données (voir section précédente), il peut donc être utilisé conjointement avec d’autres composants I2C comme le MCP23017.
Dans cet exemple, le senseur de température analogique TMP36 sera utilisé conjointement avec l’ADS1115 pour illustrer l’utilisation de l’ADC. La sortie analogique du TMP36 propose une tension de sortie proportionnelle à la température mesurée. Cette tension varie de 0,2 à 1,7 volt.
La température se calcule à l’aide de la formule suivante :
Temp en °C = ( Tension-de-sortie-en-milliVolts - 500) / 10
Il est possible d’obtenir plus d’informations sur ces deux produits dans les liens suivants :
•.ADS1115 : http://shop.mchobby.be/product.php?id_product=362
•.TMP36 : https://wiki.mchobby.be/index.php?title=Senseur_Température
Le schéma de raccordement utilise l’entrée A0, premier canal analogique, pour lire la tension de sortie du TMP36. L’adresse de l’ADS1115 sur le bus I2C est 0x48, celle-ci est obtenue en branchant la broche d’adresse ADDR sur la masse.
Pour finir, ADS1115 propose un amplificateur à gain programmable. Une fois un gain activé, il est possible de calculer la valeur en millivolts en multipliant la valeur lue par le multiplicateur repris dans le tableau ci-dessous.
Index du gain | Gamme de tension | Multiplicateur vers millivolts |
0 | 6,144V | 0,18750 |
1 | 4,096V | 0,12500 |
2 | 2,048V | 0,06250 |
3 | 1,024V | 0,03125 |
4 | 0,512V | 0,01562 |
5 | 0,256V | 0,00781 |
Quelle que soit la gamme de tension applicable, la tension maximale du convertisseur ADC est fixée au niveau logique + 0,3 volt (soit 3,6 V dans le cas présent).
Lecture analogique avec le breakout ADS1115
L’utilisation de l’ADS1115 requiert l’installation de la bibliothèque ads1x15.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/ads1015-ads1115
Seul le fichier ads11x15.py doit être transféré sur la plateforme ESP8266.
La session REPL ci-dessous reprend les instructions permettant de lire la valeur du TMP36 branché sur l’entrée A0 du breakout ADS1115.
01: >>> from machine import Pin, I2C
02: >>> from ads1x15 import *
03: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )
04: >>> adc = ADS1115( i2c, address=0x48, gain=0 )
05: >>> valeur = adc.read( rate=0, channel1=0 )
06: >>> mvolts = valeur * 0.1875
07: >>> print( mvolts )
08: 723.375
09: >>> temp = (mvolts - 500)/10
10: >>> print( "%s deg. Celcius" % temp )
11: 22.3375 deg. Celcius
Lignes 1-2 : import des classes nécessaires.
Ligne 3 : création du bus I2C.
Ligne 4 : création d’une instance de la classe ADS1115. Fixer le gain à 0 indique que la gamme de tension applicable est de 0 à 6,144 volts. Le multiplicateur est donc 0,1875.
Ligne 5 : lecture de la valeur sur l’entrée A0 (channel1=0). Les autres entrées A1, A2, A3 sont respectivement channel1=1, 2, 3.
Ligne 6 : transformation de la valeur en millivolts.
Lignes 7-8 : affichage de la tension en millivolts.
Ligne 9 : conversion de la tension (en millivolts) vers température en appliquant la formule de conversion du TMP36.
Lignes 10-11 : affichage de la température.
Plus d’informations sur l’utilisation de l’ADS1115 sous MicroPython sont disponibles sur : https://wiki.mchobby.be/index.php?title=FEATHER-MICROPYTHON-ADS1115
Cette section reprend, de façon succincte, des capteurs et composants pouvant être exploités avec l’ESP8266 sous MicroPython. Ils pourront être utilisés pour étendre les fonctionnalités du projet proposé dans ce livre.
Le senseur PIR est un senseur infrarouge utilisé pour détecter la présence par détection de mouvements. Le modèle proposé ici est assez similaire à ceux que l’on retrouve dans les systèmes d’alarme à ceci près qu’il est plutôt destiné à la réalisation de projets.
Senseur PIR
Ce modèle embarque déjà une électronique de détection et de traitement qui simplifie l’usage du senseur PIR. Une fois alimenté sous 5 V, le senseur passe le signal de sortie au niveau haut pendant environ 6 secondes lorsqu’un mouvement est détecté à proximité. Ce modèle de senseur propose un signal de sortie en 3,3 V.
Il est important de vérifier, à l’aide d’un voltmètre, que le signal de sortie est en 3,3 V (niveau logique compatible avec l’ESP8266). Si le signal de sortie est en 5 V, alors un pont diviseur de tension constitué d’une résistance de 12 Kohms et de 20 Kohms permettent de récupérer un signal de 3,12 volts aux bornes de la résistance de 20 Kohms.
Senseur PIR (signal 3,3 V) branché sur un ESP8266
Il n’est pas indiqué d’activer la résistance pull-up sur l’entrée, comme ce fut le cas pour le bouton poussoir, car le senseur PIR pilote le signal sortie +3,3 V ou 0 V.
Autre particularité du montage : le senseur PIR requiert une tension d’alimentation de 5 V. Cette alimentation est fournie par la broche USB du Feather. Cela implique que le Feather doit être branché en USB pour disposer d’une alimentation 5 V.
Le script suivant surveille l’entrée 13 et indique lorsque le senseur est actif.
01: >>> from machine import Pin
02: >>> from time import sleep
03: >>> pir = Pin( 13, Pin.IN )
04: >>> while True:
05: >>> print( ’ACTIF’ if pir.value() else ’.’ )
06: >>> sleep(0.750)
Le contact magnétique est composé d’un « interrupteur » normalement ouvert qui est maintenu fermé par un aimant généralement placé juste en face du contact. Lorsque l’aimant est éloigné du contact, celui-ci se ré-ouvre.
Le contact magnétique est généralement utilisé pour détecter l’ouverture d’une porte (système d’alarme), mais peut également être utilisé pour détecter l’ouverture d’un tiroir, d’une armoire, d’un réfrigérateur, etc.
Voici un exemple de raccordement semblable à l’utilisation d’un bouton poussoir. La broche 13 sera configurée en entrée avec activation de la résistance pull-up. Lorsque l’aimant est éloigné du contact, ce dernier s’ouvre et la broche 13 est au niveau logique haut. Lorsque l’aimant est proche du contact, celui-ci est fermé et la broche 13 est raccordée à la masse, l’entrée est au niveau logique bas.
Utilisation d’un contact magnétique
Le script suivant permet de surveiller l’entrée 13 pour détecter l’ouverture du contact magnétique. Bien que cela ne soit pas mis en œuvre dans l’exemple, il convient de procéder à un déparasitage logiciel de l’entrée, car comme pour un bouton poussoir, le contact n’est pas franc et instantané.
01: >>> from machine import Pin
02: >>> from time import sleep
03: >>> magnetic = Pin( 13, Pin.IN, Pin.PULL_UP )
04: >>> while True:
05: >>> if magnetic.value():
06: >>> print( ’PORTE OUVERTE’ )
07: >>> else:
08: >>> print( ’porte fermee’ )
09: >>> sleep( 1 )
Le DHT11 est un senseur de type environnemental abordable permettant de relever le taux d’humidité relative (senseur capacitif) ainsi que la température (thermistance). S’il n’est pas aussi précis que le DHT22 (ou AM2302), la mesure disponible reste néanmoins dans une marge d’erreur acceptable pour l’évaluation sommaire des conditions météorologiques.
Il est important de noter que, en raison de la nature même de la constitution du senseur d’humidité, celui-ci se dégrade progressivement durant son utilisation.
Senseur d’humidité et température DHT11
Le DHT11 effectue un relevé de valeur toutes les secondes et émet ensuite les données sur une broche numérique (data out). Étant donné que le senseur n’utilise pas de signal d’horloge durant la transmission d’informations, la bibliothèque doit être capable de capturer et décoder l’information en contrôlant précisément les temps de réception de chacun des bits. Cela rend la famille DHT un peu particulière, mais tout à fait exploitable sur un ESP8266, Arduino et Raspberry Pi. Le DHT11 fonctionne avec une tension d’alimentation de 3 à 5 volts.
Le DHT11 utilise une sortie à collecteur ouvert pour contrôler la broche de données (data out).
Cela signifie que le DHT11 à la possibilité de commuter la sortie à la masse pour ramener la tension de la broche de sortie à 0 V. Il est donc nécessaire d’adjoindre une résistance pull-up (souvent de 10 Kohms) pour forcer l’état de la sortie de donnée au niveau logique souhaité (généralement la tension d’alimentation).
Le montage suivant indique comment brancher un DHT11 sur le Feather ESP8266.
Raccordement d’un DHT11 sur le Feather ESP8266
La bibliothèque DHT utilisée dans l’exemple ci-dessous est déjà incluse dans le firmware MicroPython pour ESP8266.
Voici un exemple de code, saisi dans une session REPL, permettant de tester le senseur DHT11.
01: >>> from machine import Pin
02: >>> from dht import DHT11
03: >>> d = DHT11( Pin(12) )
04: >>> d.measure()
05: >>> d.temperature()
06: 22
07: >>> d.humidity()
08: 37
Ligne 2 : importation de la classe DHT11 (bibliothèque incluse dans le firmware MicroPython). La bibliothèque « dht » inclut également une classe DHT22.
Ligne 3 : créer une instance de la classe DHT11 en indiquant la broche de donnée.
Ligne 4 : synchronisation et lecture des informations sur la broche de données. Si les tentatives de synchronisation échouent alors la fonction lève une exception.
Lignes 5-6 : affichage de la température (en degrés Celsius) acquise durant l’appel de la fonction measure().
Lignes 7-8 : affichage de l’humidité relative (en pourcentage) acquise durant l’appel de la fonction measure().
Vous trouverez plus d’informations sur le DHT11, le DHT22 et un accès à divers tutoriels depuis les liens suivants :
•.AM2302 : à base de DHT22 http://shop.mchobby.be/product.php?id_product=214
Le senseur à effet Hall est sensible au champ magnétique. Dans cette catégorie de senseurs, il existe des senseurs analogiques et numériques. Les senseurs analogiques renvoient une tension de sortie en relation avec l’intensité du champ magnétique tandis que les senseurs numériques, comme le US5881LUA, indiquent simplement la présence ou non du champ magnétique.
Senseur à effet Hall et aimant des terres rares
Un senseur à effet Hall numérique est pratique pour réaliser un « interrupteur » à placer dans un emplacement/environnement ne permettant pas l’utilisation d’un interrupteur/détecteur de type mécanique. Il peut être utilisé conjointement avec un flotteur aimanté pour détecter un niveau, un aimant des terres rares pour détecter l’ouverture d’un objet particulier (ex. : le couvercle d’une poubelle), la rotation d’un axe, etc.
Le senseur USS5881LUA fonctionne sous une large gamme de tensions allant de 3,3 V à 24 V. Le senseur utilise une sortie à collecteur ouvert (voir les explications du DHT11 ci-dessus) qui place le signal à la masse lorsque le senseur détecte le champ magnétique d’un pôle sud. Le signal au niveau haut (+3,3 V) est obtenu, en l’absence de champ magnétique, à l’aide d’une résistance pull-up de 10 Kohms branchée sur la broche signal.
Montage d’un senseur à effet Hall numérique sur le Feather ESP8266
Les instructions suivantes saisies dans une session REPL permettent de contrôler le fonctionnement du senseur.
01: >>> from machine import Pin
02: >>> from time import sleep
03: >>> p = Pin( 12, Pin.IN )
04: >>> while True:
05: >>> if p.value():
06: >>> print(".")
07: >>> else:
08: >>> print( "AIMANT present" )
09: >>> sleep( 1 )
Le senseur s’active lorsque le pôle sud d’un aimant est présenté sur la face avant du senseur (la face portant les inscriptions).
Les instructions produisent un résultat similaire à ceci :
.
.
.
AIMANT present
AIMANT present
AIMANT present
AIMANT present
.
.
AIMANT present
AIMANT present
.
.
.
Le TSL2561 est un senseur I2C permettant de mesurer précisément le niveau de luminosité. Il retourne une valeur en lux allant de 0,1 à 40 000 lux. Un tel senseur est beaucoup plus précis qu’une photorésistance et offre une réponse plus proche de celle de l’œil humain. En effet, la photorésistance ne permet qu’une évaluation grossière de la luminosité (s’il fait jour ou nuit). Les mesures du TSL2561 permettent d’évaluer les conditions de luminosité/ensoleillement, ce qui rend ce composant tout à fait indiqué pour la constitution d’une station météo, d’un projet photo et d’automatisation de jardin.
Voici quelques valeurs typiques de luminosité et leurs correspondances :
•.0,2 lux : minimum de luminosité que doit produire un éclairage d’urgence
•.0,5 lux : pleine lune par temps clair
•.3,4 lux : limite crépusculaire au couché du soleil en zone urbaine
•.50 lux : éclairage d’un living room
•.80 lux : éclairage des toilettes
•.100 lux : journée très sombre, temps très couvert
•.300 - 500 lux : levé du soleil, luminosité par temps clair
•.300 - 500 lux : bureau correctement éclairé
•.1,000 lux : temps couvert
•.1,000 lux : éclairage typique d’un studio TV
•.10,000 - 25,000 lux : éclairage en pleine journée, pas de soleil direct
•.32,000 - 130,000 lux : soleil direct
L’image ci-dessous présente le breakout TSL2561 produit par Adafruit Industries, breakout qui fonctionne sur une gamme de tensions d’alimentation de 2,7 V à 5 V.
Breakout TSL2561 d’Adafruit Industries
L’adresse par défaut du senseur I2C est 0x39. Elle est obtenue en laissant la broche d’adresse flottante. Cette adresse peut être configurée sur 0x29 en plaçant la broche au niveau bas ou 0x49 en plaçant la broche d’adresse au niveau haut.
L’utilisation de ce senseur implique deux paramètres de configuration importants qui sont :
•.le gain (x1 ou x16)
•.le temps d’intégration
Le gain x1 permet d’utiliser le senseur dans des conditions de luminosité moyenne à forte tandis que le gain x16 permet de réaliser des mesures en lumière diffuse. À noter que la fonctionnalité « auto-gain » facilite l’utilisation du senseur.
Le temps d’intégration (13 ms, 101 ms, 402 ms) permet de collecter plus ou moins de données sur la luminosité. Plus le temps d’intégration est long et plus précise sera la mesure. Le temps d’intégration de 402 millisecondes est le seul à offrir une résolution de 16 bits, tandis que le temps d’intégration de 13 ms offrira une réponse beaucoup plus rapidement, mais au prix d’une résolution plus faible.
Le montage ci-dessous permet de mesurer la luminosité en utilisant le breakout TSL2561 avec son adresse par défaut 0x39 (broche d’adresse flottante).
Montage du breakout TSL2561
L’utilisation du TSL2561 requiert l’installation de la bibliothèque tsl2561.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/tsl2561
Seul le fichier tsl2561.py doit être transféré sur la plateforme ESP8266.
La session REPL, reprise ci-dessous, montre comment exploiter le senseur.
01: >>> from machine import I2C, Pin
02: >>> from tsl2561 import *
03: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )
04: >>> tsl = TSL2561( i2c )
05: >>> print( tsl.read() )
06: 3.01739
07: >>> print( tsl.read() )
08: 16.2582
09: >>> print( tsl.read() )
10: 3.01739
11: >>> tsl.gain( 16 )
12: >>> tsl.integration_time( 402 )
13: >>> print( tsl.read() )
14: 3.37176
15: >>> print( tsl.read( autogain=True ) )
16: 3.394
Voici quelques explications concernant les différentes commandes utilisées durant la session REPL :
Lignes 1-3 : importation des classes nécessaires et création d’une instance du bus I2C (sur les broches 4 et 5).
Ligne 4 : création d’une instance du senseur TSL2560 (le senseur de luminosité).
Ligne 5 : la méthode read() active le senseur, effectue la mesure et retourne une valeur en lux. Valeur affichée avec l’instruction print().
Ligne 6 : le senseur a retourné une mesure de 3,01 lux. Cela correspond à un milieu sombre (limite crépusculaire au couché du soleil). C’est effectivement le cas étant donné les conditions de luminosité sur le banc d’essai.
Lignes 7-8 : allumer un point d’éclairage néon à proximité du senseur améliore les conditions de luminosité (16 lux) sans pour autant atteindre le niveau de luminosité idéal d’un salon (50 lux).
Lignes 9-10 : retour aux conditions d’éclairage des lignes 5 et 6.
Ligne 11 : en situation de faible luminosité, il est possible d’augmenter le gain du senseur pour obtenir une meilleure précision.
Ligne 12 : augmenter le temps d’intégration à 402 ms afin d’obtenir une mesure plus précise. Ce qui est tout à fait indiqué compte tenu de la faible luminosité ambiante.
Lignes 13-14 : l’augmentation du temps d’intégration et du gain permettent d’affiner la résolution de la mesure (de 3,017 à 3,371 lux).
Ligne 15 : à noter que la méthode read() dispose du paramètre optionnel autogain qui permet de sélectionner automatiquement le gain x1 ou x16 en fonction des conditions de luminosité.
Vous trouverez plus d’informations sur ce senseur dans les tutoriels suivants :
•.TSL2561 avec Arduino : http://wiki.mchobby.be/index.php?title=TSL2561-Utiliser
•.TSL2561 et MicroPython : https://wiki.mchobby.be/index.php?title=FEATHER-MICROPYTHON-TSL2561
Le BME280 de Bosch est un senseur abordable qui se présente comme un véritable couteau suisse de mesures environnementales pour makers. Avec ce seul composant et un peu d’électronique, il est possible de mesurer trois paramètres importants de notre environnement quotidien :
•.la pression atmosphérique avec pression absolue à ± 1 hPa (de 300 hPa à 1000 hPa)
•.la température avec une précision de ± 1 °C (de -40 °C à +85 °C)
•.le taux d’humidité relative avec une précision de ± 1 % (de 0 à 100 %RH)
Le BME280 peut s’utiliser aussi bien sur un bus I2C que sur un bus SPI. Adafruit Industrie a monté le BME280 sur un breakout de sorte qu’il est possible de l’utiliser sur des systèmes en logique 3,3 V ou 5 V.
Breakout BME280 d’Adafruit Industries
Le montage suivant indique comment brancher un BME280 sur le Feather ESP8266 en utilisant le bus I2C. Le breakout BME280 est alimenté en 3,3 V depuis la carte Feather via la broche 3 V (derrière le régulateur de tension). Il est également possible d’alimenter la carte de 3,3 V à 5 V via la broche VIN (donc avant le régulateur de tension du breakout).
Montage du breakout BME280
L’utilisation du BME280 requiert l’installation de la bibliothèque bme280.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/bme280-bmp280
Seul le fichier bme280.py doit être transféré sur la plateforme ESP8266.
La session REPL, reprise ci-dessous, montre comment exploiter le senseur :
01: >>> from machine import I2C, Pin
02: >>> from bme280 import *
03: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )
04: >>> bme = BME280( i2c=i2c )
05: >>> bme.values
06: (’21.21C’, ’993.88hPa’, ’50.33%’)
07: >>> bme.raw_values
08: (20.74, 993.97, 50.79)
Ligne 2 : import de la classe BME280 depuis la bibliothèque bme280.py.
Ligne 3 : création d’une instance du bus I2C.
Ligne 4 : création d’une instance de la classe BME280 (variable bme) en passant le bus I2C en paramètre. La classe BME280 utilisera l’adresse par défaut du senseur (0x76).
Ligne 5 : lecture des valeurs du senseur. values est une propriété de la classe BME280. Celle-ci retourne un tuple Python contenant trois valeurs sous forme de chaînes de caractères prêtes à être affichées.
Ligne 6 : affichage des trois valeurs obtenues via la propriété values dans la session REPL. Soit la température en degrés Celsius, la pression en hectopascal, l’humidité relative en pourcentage.
Ligne 7 : obtention du même tuple de valeurs sous forme de données typées, ce format est plus approprié au traitement de données.
Ligne 8 : affichage des valeurs typées dans la session REPL.
Le module relais est assurément la méthode la plus simple permettant de contrôler un appareillage haute puissance ou haute tension (comprenez une tension supérieure à 3,3 V). Si les relais sont souvent taxés de gaspilleur d’énergie, ils ont néanmoins l’avantage d’offrir une isolation galvanique qui sépare totalement le circuit haute tension du circuit basse tension.
L’activation d’un relais nécessite une électronique de commande adéquate, raison pour laquelle les modules relais préassemblés sont plus confortables à mettre en œuvre puisqu’ils peuvent être branchés directement sur une sortie du microcontrôleur.
Module bi-relais produit par la société Pololu
Compte tenu du nombre limité d’entrées/sorties sur un ESP8266, il est rarement facile d’utiliser plus d’un ou deux relais directement sur le microcontrôleur. En utilisant un MCP23017 (GPIO Expander, présenté ci-avant dans le chapitre), il devient alors possible de commander de très nombreux relais.
Certains éléments doivent retenir l’attention de l’acheteur lorsqu’il sélectionne un module relais destiné à une utilisation sur système en logique 3,3 V. Car la plupart de ces cartes relais nécessitent une tension d’alimentation de 5 V (pour l’activation des relais).
Voici quelques recommandations à destination des néophytes :
•.Le relais doit s’activer avec un signal de commande au niveau haut ! Une tension de 3,3 V est généralement suffisante pour activer la plupart des cartes relais même si celles-ci sont alimentées en 5 V.
•.Éviter les cartes relais qui s’activent en plaçant le signal de commande au niveau bas, car cela signifie qu’il y a, sur la carte relais, une résistance de rappel (pull-up) qui ramène la tension de la broche de commande à 5 V. Ces 5 V détruiront l’entrée/sortie de l’ESP8266 et entraîneront la destruction du microcontrôleur.
•.Toujours vérifier le courant nécessaire à l’activation de la broche de commande. Placer un multimètre en position ampèremètre entre la broche de commande et le +3,3 V permet de relever le courant d’activation. Ce courant devra être fourni par la sortie du microcontrôleur et doit donc rester dans les limites matérielles de celui-ci. Un courant de l’ordre du milliampère est tout à fait convenable.
Le montage suivant utilise les sorties 13 et 14 pour commander deux relais. La carte relais est alimentée en 5 V par l’intermédiaire de la broche USB fournissant la tension de 5 V et le courant nécessaire à l’activation des relais. L’alimentation est fournie via le connecteur USB. Pour finir, la séparation galvanique permet de commander deux points d’éclairage 12 V en courant continu via le contact normalement ouvert des relais.
Utilisation du module bi-relais sur l’ESP8266
Les instructions suivantes saisies dans une session REPL permettent de contrôler les deux relais indépendamment l’un de l’autre.
01: >>> from machine import Pin
02: >>> rel1 = Pin( 14, Pin.OUT )
03: >>> rel2 = Pin( 13, Pin.OUT )
04: >>> rel1.value( 1 )
05: >>> rel2.value( 1 )
06: >>> rel2.value( 0 )
07: >>> rel1.value( 0 )
Ligne 1 : import de la classe Pin permettant de contrôler les sorties.
Ligne 2 : déclaration du relais rel1 attaché à la broche 14 (voir raccordement). La broche est configurée en sortie avec le paramètre Pin.OUT.
Ligne 3 : déclaration pour le deuxième relais rel2 attaché à la broche 13.
Ligne 4 : activation de la sortie 14, donc du premier relais (broche IN1 du module relais). Ce qui active le relais 1 et allume la lampe L1.
Ligne 5 : activation du second relais et donc de la lampe L2.
Si la carte Feather est branchée sur le port USB du Raspberry Pi, ce dernier devra fournir le courant nécessaire au fonctionnement du Feather (interface Wi-Fi) et des relais. Dans ce cas de figure, l’activation du deuxième relais n’est pas toujours probante, car le courant disponible sur l’interface USB du Pi plafonne à 110~130 mA. L’utilisation d’un bloc d’alimentation 5 V avec fiche micro USB permet de palier cette limitation.
Ligne 6 : désactivation du deuxième relais. La lampe L2 s’éteint.
Ligne 7 : désactivation du premier relais. La lampe L1 s’éteint.
Usage en haute tension
Il est important de rappeler que les raccordements réalisés sur les réseaux électriques ayant une tension supérieure à 48 V alternatif (ex. : réseau domestique) ou 24 V continu peuvent être source d’accidents graves.
Par ailleurs, un relais sous-dimensionné en termes de puissance (contrôlant un matériel trop puissant) peut être le point de départ d’un incendie.
Un contact accidentel avec le circuit haute tension peut provoquer de sévères brûlures dans le meilleur des cas et la mort dans les cas les plus graves.
Il est vivement recommandé de s’entourer de personnes ayant les compétences adéquates dès lors que la puissance ou la tension mises en œuvre deviennent importantes !
Interfacer l’ESP8266 avec du matériel est un grand pas dans la réalisation d’objets Internet. Cette section se penche sur le deuxième pilier : la communication.
La section suivante se penche en particulier sur la communication avec le broker MQTT.
Pour rappel, le broker MQTT est installé sur le Raspberry Pi avec la configuration suivante :
•.Raspberry Pi configuré en adresse IP fixe : 192.168.1.210
•.Broker MQTT avec login et mot de passe : puser103 / 21052017
Le script d’exemple mqtt_pub.py détaillé ci-dessous effectue une série de publications sur le broker MQTT.
Une copie de cet exemple est disponible dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy.
01: """ La Maison Pythonic - publication sur broker MQTT depuis
02: MicroPython """
03: import time
04: from network import WLAN
05: from umqtt.simple import MQTTClient
06: from ubinascii import hexlify
07: import sys
08:
09: CLIENT_ID = "demo-pub"
10:
11: MQTT_SERVER = "192.168.1.210"
12:
13: # Mettre a None si pas utile
14: MQTT_USER = ’pusr103’
15: MQTT_PSWD = ’21052017’
16:
17: print( "Creation MQTTClient")
18: q = MQTTClient( client_id = CLIENT_ID, server =
19: MQTT_SERVER, user = MQTT_USER, password = MQTT_PSWD )
20:
21: if q.connect() != 0:
22: print( "erreur connexion" )
23: sys.exit()
24: print( "Connecté" )
25:
26: # annonce connexion objet
27: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
28: q.publish( "connect/%s" % CLIENT_ID , sMac )
29:
30: # publication d’un compteur
31: for i in range( 10 ):
32: print( "pub %s" % i )
33: q.publish( "demo/compteur", str(i) )
34: time.sleep( 1 )
35:
36: q.disconnect()
37: print( "Fin de traitement" )
Ligne 5 : importation du client MQTT.
Ligne 9 : identification du client MQTT (permet d’identifier l’objet internet).
Ligne 11 : adresse IP du broker MQTT sur le réseau local. Peut également être un nom de domaine comme test.mosquitto.org ou un nom d’hôte sur le réseau local comme pythonic.local (voir ci-dessous la note concernant la résolution DNS).
Lignes 14-15 : identification utilisateur demandés pour établir la connexion avec le broker MQTT. Ces paramètres peuvent être à None s’ils ne sont pas nécessaires, ce qui serait le cas sur le broker MQTT de test de Mosquitto (test.mosquitto.org).
Ligne 18 : création d’une instance de la classe MQTTClient en passant les paramètres de connexion.
Ligne 21 : établissement de la connexion avec le broker.
Ligne 23 : fin d’exécution du script en cas d’erreur de connexion.
Ligne 27 : transformation de l’adresse MAC de l’ESP en chaîne de caractères unicode. La fonction hexlify() transforme une chaîne de caractères en sa représentation hexadécimale. hexlify( "hello" ) produit b’68656c6c6f’. La fonction hexlify() retourne un type bytes (un tableau d’octets) qu’il faut transformer en chaîne de caractères unicode avant de réaliser une publication MQTT. La fonction decode() permet de transformer le type bytes en chaîne unicode. type( b’68656c6c6f’.decode() )’ renvoie <class ’str’>.
Ligne 28 : publication de l’adresse MAC sur le topic connect/demo-pub (demo-pub étant le ClientId de l’objet Internet). Cette approche est utilisée pour communiquer la connexion de l’objet IoT sur le broker. L’utilisation de l’adresse MAC dans le message permet d’identifier chaque objet sans équivoque (puisque les adresses MAC sont uniques).
Lignes 31-33 : utilisation d’une boucle for pour publier 10 messages (valeurs de 0 à 9) sur le topic demo/compteur. L’instruction time.sleep(1) permet d’éviter l’envoi des messages en rafale.
Ligne 36 : déconnexion du client MQTT.
Tester la publication
Les publications pourront être consultées à l’aide de l’utilitaire mosquitto_sub réalisant une souscription sur la totalité des topics à l’aide du filtre « # ». La souscription doit, bien entendu, avoir pris place avant l’exécution du script MicroPython.
mosquitto_sub -h pythonic.local -t "#" -u "pusr103" -P "21052017" -v
Après avoir réalisé la souscription, une session REPL (ouverte depuis un autre terminal) permet de lancer le script de test à l’aide de import mqtt_py.
Script MicroPython effectuant des publications
Comme le montre la capture ci-dessous, l’utilitaire mosquitto_sub permet de constater la publication des différents messages du script MicroPython. L’option -V affiche également le topic sur lequel le message est publié.
mosquitto_sub, affichage des messages publiés sur le broker MQTT
À propos de la résolution DNS
Le script Python inclut la définition de la constante MQTT_SERVER permettant d’identifier le broker MQTT sur lequel le client doit se connecter.
Dans cet exemple, l’adresse IP du broker est utilisée car les tests sont conduits sur un réseau domestique géré par le modem-routeur du fournisseur d’accès Internet.
MicroPython n’implémente pas le protocole mDns facilitant la résolution du nom d’hôte sur un réseau domestique (ex. : MQTT_SERVER = "pythonic.local") vers une adresse IP. En conséquence, le succès de cette résolution repose intégralement sur la capacité du modem-routeur à capturer la correspondance adresses IP <-> nom d’hôtes.
L’expérience a démontré que cette résolution n’est pas fiable sur tous les modems-routeurs, ni stable dans le temps. En conséquence, le Raspberry Pi sur lequel est installé le broker MQTT a été configuré avec une adresse IP fixe (192.168.1.210) pour contourner les problèmes de fiabilité de résolution DNS sur le réseau domestique.
Le script d’exemple mqtt_sub.py détaillé ci-dessous réalise une souscription demo/# pour capturer tous les topics sous demo. Les messages sont affichés dans la session REPL. Le topic cmd/led permet de commander la LED raccordée sur la broche 0 avec les messages on et off.
Une copie de cet exemple est disponible dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy.
01: """ La Maison Pythonic - souscription sur broker MQTT
02: depuis MicroPython """
03: import time
04: from network import WLAN
05: from machine import Pin
06: from umqtt.simple import MQTTClient
07: from ubinascii import hexlify
08: import sys
09:
10: CLIENT_ID = "demo-sub"
11:
12: MQTT_SERVER = "192.168.1.210"
13:
14: # Mettre a None si pas utile
15: MQTT_USER = ’pusr103’
16: MQTT_PSWD = ’21052017’
17:
18: led = Pin( 0, Pin.OUT )
19: led.value( 1 ) # eteindre
20:
21: def sub_cb( topic, msg ):
22: """ fonction de rappel pour souscription MQTT """
23: t = topic.decode(’utf8’)
24: m = msg.decode(’utf8’)
25: print( ’-’*20 )
26: print( ’topic: %s’ % t )
27: print( ’message: %s’ % m )
28:
29: if t == ’cmd/led’:
30: print( "changement etat led")
31: # LED en logique inversée
32: led.value( 1 if m=="off" else 0 )
33:
34: print( "Création MQTTClient")
35: q = MQTTClient( client_id = CLIENT_ID, server =
36: MQTT_SERVER, user = MQTT_USER, password = MQTT_PSWD )
37: q.set_callback( sub_cb )
38:
39: if q.connect() != 0:
40: print( "erreur connexion" )
41: sys.exit()
42: print( "Connecté" )
43:
44: q.subscribe( ’demo/#’ )
45: q.subscribe( ’cmd/led’ )
46: print( "souscription OK" )
47:
48: # annonce connexion objet
49: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
50: q.publish( "connect/%s" % CLIENT_ID , sMac )
51:
52: # Boucle de traitement
53: while True:
54: # traitement message MQTT (BLOQUANT)
55: q.wait_msg()
56:
57: # traitement message MQTT (NON BLOQUANT)
58: # q.check_msg()
59:
60: q.disconnect()
Lignes 3-8 : importation des bibliothèques nécessaires.
Lignes 10-16 : définition des constantes utiles. CLIENT_ID est utilisé pour identifier l’objet Internet. MQTT_SERVER identifie le serveur MQTT à contacter, voir la note concernant la résolution DNS dans la section précédente. MQTT_USER et MQTT_PSWD sont les login et mot de passe autorisant la connexion sur le broker MQTT. Ces deux dernières valeurs peuvent être à None si le broker autorise les connexions anonymes.
Lignes 18-19 : activation de la broche #0 en sortie, broche sur laquelle est branchée la LED rouge du Feather ESP8266. Cette LED fonctionne en logique inverse (cf. Broches d’entrée/sortie dans ce chapitre). La sortie est placée au niveau haut pour éteindre la LED.
Lignes 21-32 : définition de la fonction de rappel sub_cb() qui sera appelée par MQTTClient lorsqu’un message est reçu par le broker. Les détails de la fonction sub_cb() sont abordés un peu plus loin.
Ligne 35 : création d’une instance de l’objet MQTTClient avec tous les paramètres nécessaires à la connexion. À ce stade, la connexion n’est pas encore établie avec le broker MQTT.
Ligne 37 : assignation de la fonction de rappel sub_cb() sur le client MQTTClient. Cette assignation doit se faire avant la connexion au broker.
Lignes 39-41 : connexion sur le broker MQTT. Si la connexion est refusée, alors l’exécution du script est interrompue à l’aide de sys.exit().
Ligne 44 : souscription à tous les sous-topics de « demo » (à l’aide de l’expression de filtrage demo/#). Note : la souscription demo/+ permettrait de capturer un seul sous-niveau.
Ligne 45 : souscription à un topic donné demo/led. La réception des messages « on » et « off » sur ce topic permettra de commander la LED branchée sur la broche #0. Voir détail de la fonction de rappel sub_cb() ci-dessous.
Lignes 49-50 : annonce de la connexion de l’objet sur le broker MQTT. Le détail de ces lignes est abordé dans le chapitre précédent concernant la publication de messages.
Lignes 53-55 : boucle de traitement des messages MQTT. L’instruction while True : assure une boucle de traitement infinie. L’instruction q.wait_msg() attend qu’un message correspondant aux souscriptions soit communiqué par le broker. Une fois celui-ci reçu, la fonction de rappel sub_cb() est appelée pour traiter le message.
Ligne 60 : déconnexion du broker (pour information). Les messages étant traités dans une boucle infinie (voir ligne 53), cette ligne ne sera jamais exécutée.
Les méthodes wait_msg() et check_msg()
La méthode wait_msg() de la classe MQTTClient permet à MQTTClient de traiter un message (et un seul) en provenance du broker MQTT. Message distribué au client MQTT suite à une ou plusieurs souscriptions (voir lignes 44 et 45) effectuées sur le broker.
Lorsque le message est reçu, wait_msg() passe le relais à la fonction de rappel utilisateur sub_cb() assignée à l’aide de l’instruction q.set_callback(sub_cb). La fonction de rappel, définie dans le script utilisateur, est la seule responsable du traitement des messages reçus depuis le broker MQTT.
Il est important de noter que la méthode wait_msg() est bloquante ! Cette dernière ne rend la main au script principal qu’après avoir reçu un message du broker. S’il n’y a pas de réception de message avant une heure, alors la fonction wait_msg() bloque l’exécution du script pendant une heure. L’utilisation de la méthode wait_msg() est parfaite si l’objet en cours de développement n’a pas d’autres tâches à réaliser que d’attendre les messages entrants.
Si l’objet doit également communiquer des informations à intervalle régulier (publication de la température), alors la méthode check_msg() sera utilisée à la place de wait_msg().
La méthode check_msg() est non bloquante. Si le message est présent, il est traité comme le fait la fonction wait_msg(). S’il n’y a pas de message en attente, la méthode rend la main immédiatement au script principal.
Un maximum de un message est traité par appel de wait_msg() ou check_msg(). Par conséquent, la rapidité de traitement des messages entrants dépend (1) de la durée de traitement de la fonction de rappel sub_cb() et (2) du temps nécessaire au script principal pour faire l’appel suivant à wait_msg() / check_msg().
La fonction de rappel sub_cb()
La fonction de rappel est appelée par wait_msg() ou check_msg() lorsqu’un message entrant doit être traité par le client MQTT.
La fonction de rappel de l’exemple ci-dessous est appelée avec deux paramètres : le topic et le message.
def sub_cb( topic, msg ):
...
Cette fonction de rappel est l’unique point d’entrée pour toutes les souscriptions. Il faut donc tester la valeur du paramètre topic pour exécuter le traitement adéquat en fonction du topic et du message reçu.
À noter que ces paramètres sont des chaînes d’octets (type bytes).
21: def sub_cb( topic, msg ):
22: """ fonction de rappel pour souscription MQTT """
23: t = topic.decode(’utf8’)
24: m = msg.decode(’utf8’)
25: print( ’-’*20 )
26: print( ’topic: %s’ % t )
27: print( ’message: %s’ % m )
28:
29: if t == ’cmd/led’:
30: print( "changement etat led")
31: # LED en logique inversée
32: led.value( 1 if m=="off" else 0 )
Ligne 21 : déclaration de la fonction de rappel avec les deux paramètres transmis par MQTTClient. Le nom de la fonction sub_cb correspond à « subcriber callback ».
Ligne 23 : le paramètre topic est de type bytes (une chaîne d’octets) qu’il faut transformer en chaîne de caractères pour permettre la comparaison avec une autre chaîne de caractères. C’est ce que fait la fonction decode().
Ligne 24 : conversion du message du type bytes vers type str.
Lignes 25-27 : affichage du topic et du message dans la console REPL pour faciliter le débogage.
Ligne 29 : branchement pour le traitement spécifique des messages du topic cmd/led.
Ligne 32 : modification de l’état de la broche #0 qui contrôle la LED branchée en logique inverse. L’expression ternaire 1 if m=="off" else 0 retourne la valeur 1 (donc éteint la LED) si le message contient « off », sinon retourne la valeur 0 (donc allume la LED).
Tester la souscription
La souscription peut être testée sur l’ESP8266 en saisissant la ligne de commande import mqtt_sub depuis une session REPL. La session REPL permettra la capture des différents messages produits par les fonctions print().
L’utilisation d’un second terminal sur le Raspberry Pi permet de publier différents messages sur le broker MQTT comme indiqué dans la capture suivante :
Publication manuelle de messages sur le broker MQTT
Le résultat des différentes publications peut être constaté dans la session REPL MicroPython visible dans la capture ci-dessous.
Session REPL affichant les messages reçus par la fonction de rappel
Le test met en évidence :
•.que le message publié sur le topic hors/filtre (hors des souscriptions de l’objet Internet) n’est pas envoyé par le broker vers l’ESP8266,
•.que la publication des messages sur le topic cmd/led permet effectivement de contrôler la LED sur la broche #0.
Un sous-ensemble de Asyncio est disponible dans le firmware MicroPython depuis la release 1.9.3 (cf. module uasynio).
Asyncio sera utilisé dans le présent projet pour réaliser un planificateur de tâches (scheduler) en vue d’exécuter des opérations à intervalles réguliers. Celles-ci sont aussi variées que :
•.publier des données sur le broker MQTT,
•.faire clignoter la LED heartbeat,
•.traiter les messages MQTT entrants.
Asyncio permet d’exécuter plusieurs sections de code (des fonctions, dites « coroutines ») de manière asynchrone. Quand une section de code termine (ou suspend) son exécution, alors Asyncio peut passer la main à une autre section de code à exécuter.
Attention, il ne s’agit pas de traitement multitâche en parallèle. La boucle d’exécution d’Asyncio utilise un seul et unique processus pour gérer l’exécution, l’une après l’autre, des sections de codes. Elle permet un traitement des sections de code sans blocage en utilisant un mode coopératif.
Étant donné que cette boucle d’exécution et les sections de codes fonctionnent dans un seul et même processus, cela implique que toutes ces sections de code partagent le même espace mémoire. Les variables globales sont donc accessibles par toutes les fonctions. De même, une section de code modifiant le contenu d’une variable globale ne risque pas d’entrer en conflit avec une autre puisqu’il n’y a qu’une seule section de code exécutée à un moment donné.
L’utilisation d’Asyncio n’améliore pas les temps d’exécution, car il n’y a pas de traitement parallèle des tâches. Par contre, Asyncio simplifie le développement de scripts mettant en œuvre diverses tâches asynchrones, développement qui serait nettement plus difficile à réaliser avec des méthodes de programmation de base.
Asyncio permet un traitement multitâche de type coopératif pour un très faible coût en termes de ressources, ce qui est idéal pour un environnement d’exécution à base de microcontrôleur.
Le script d’exemple scheduler_asyncio.py détaillé ci-dessous met en évidence le traitement de plusieurs opérations en mode coopératif en vue de réaliser un planificateur de tâches, qui affichent des messages à intervalle régulier. La première affiche un message toutes les secondes tandis que la deuxième affiche un autre toutes les 1,2 secondes.
Une copie de cet exemple est disponible dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy. À noter qu’il sera nécessaire de renommer le fichier durant la copie afin que son nom ne dépasse pas 8 caractères.
01: # coding: utf8
02: """ Utilisation de asyncio pour planifier des taches.
03:
04: Voir également asyncio et exemples
05: https://github.com/peterhinch/micropython-async """
06:
07: import uasyncio as asyncio
08:
09: async def print_this( s, time_ms ):
10: while True:
11: await asyncio.sleep_ms( time_ms )
12: print( s )
13:
14: loop = asyncio.get_event_loop()
15: loop.create_task( print_this( "every sec", 1000 ) )
16: loop.create_task( print_this( "every 1.2sec", 1200 ) )
17:
18: loop.run_forever()
19:
20: #async def killer( sec ):
21: # await asyncio.sleep( sec )
22: #
23: #loop.run_until_complete( killer( sec=25 ) )
24: #print( "Execution terminee")
25:
26: loop.close()
Ligne 7 : importe la microbibliothèque uasyncio sous l’espace de noms asyncio.
Ligne 9 : définition d’une fonction asynchrone (une coroutine) qui sera appelée par la boucle d’exécution asyncio. La fonction prend en paramètre le message à afficher et le temps de pause (en millisecondes) entre deux affichages consécutifs.
Ligne 10 : boucle infinie, cette fonction exécute le bloc d’instructions contenu de façon répétitive.
Ligne 11 : faire une pause d’une durée de time_ms millisecondes en rendant la main à la boucle de traitement asyncio. De la sorte, d’autres tâches ont éventuellement l’opportunité d’être exécutées. Au terme du délai d’attente, la boucle de traitement asyncio continue l’exécution de la fonction et passe à la ligne 12.
Ligne 12 : exécution de la partie « tâche » de la fonction, symbolisée par une instruction print().
Ligne 14 : création de la boucle de traitement asyncio.
Lignes 15-16 : création de deux tâches distinctes utilisant la fonction asynchrone print_this(). La fonction asynchrone print_this() sera donc exécutée deux fois, à tour de rôle, avec des contextes différents pour chaque tâche.
Ligne 18 : exécution continue de la boucle de traitement asyncio sans condition de sortie.
Lignes 20-24 : lignes en commentaires et non exécutées dans cette version du script (voir plus loin).
Ligne 26 : libération des ressources asyncio. Techniquement, cette ligne n’est pas exécutée dans cette version du script puisque la ligne 18 s’exécute indéfiniment.
Tester le script d’exemple
Après avoir copié le script scheduler_asyncio.py sur la plateforme MicroPython en ayant pris soin de le renommer aio_demo.py (maximum 8 caractères), il est possible d’en tester le contenu dans une session REPL à l’aide de l’instruction import aio_demo. Presser [Ctrl] C pour interrompre l’exécution du script.
L’exécution du script produit le résultat suivant :
Session REPL affichant le résultat de aio_demo.py
Le résultat démontre que les 200 ms supplémentaires de la deuxième tâche s’accumulent et portent à conséquence au bout de 5 itérations (voir la succession des deux messages « every sec » successifs).
Condition de sortie
La boucle de traitement asyncio peut être interrompue avec une condition de sortie en remplaçantl’appel de loop.run_forever() par loop.run_until_complete(fct_de_ test). La boucle de traitement asyncio termine son exécution dès lors que la fonction de test fct_de_test() achève son traitement. Cette fonctionnalité est très pratique pour interrompre le traitement du script en fonction de la position d’un interrupteur (cf. Séquence de démarrage MicroPython - RunApp - Activation de l’application dans ce chapitre).
La fonction de test est également une fonction asynchrone comme le démontre l’exemple utilisant la fonction killer().
Dans le code de scheduler_asyncio.py, remplacez la section code suivant :
18: loop.run_forever()
19:
20: #async def killer( sec ):
21: # await asyncio.sleep( sec )
22: #
23: #loop.run_until_complete( killer( sec=25 ) )
24: #print( "Execution terminee")
25:
26: loop.close()
par
18: #loop.run_forever()
19:
20: async def killer( sec ):
21: await asyncio.sleep( sec )
22:
23: loop.run_until_complete( killer( sec=25 ) )
24: print( "Execution terminee")
25:
26: loop.close()
Lignes 20-21 : définition de la fonction asynchrone killer() qui attend x secondes avant de terminer son exécution. Le temps de pause rend la main à la boucle de traitement asyncio.
Ligne 23 : exécution de la boucle de traitement asyncio. Boucle qui s’achèvera lorsque la fonction killer( sec=25 ) termine son traitement. Pour rappel, cette fonction fait une simple pause de 25 secondes !
Ligne 24 : affichage d’un message signalant la fin d’exécution de la bouche de traitement.
Ligne 26 : libération des ressources asyncio.
Tester le script modifié
Après avoir modifié et copié le script scheduler_asyncio.py sur la plateforme MicroPython en ayant pris soin de le renommer aio_demo.py (maximum 8 caractères), il est possible d’en tester le contenu dans une session REPL à l’aide de l’instruction import aio_demo. Cette fois, il n’est pas nécessaire de terminer l’exécution du script, celui-ci s’interrompra tout seul au bout de 25 secondes.
L’exécution du script produit le résultat suivant :
Session REPL affichant le résultat de aio_demo.py
Le projet développe différents objets internet dont chacun publie des informations à intervalle régulier sur le broker MQTT. Asyncio est tout indiqué pour le traitement de ces tâches répétitives.
L’utilisation d’une fonction asynchrone run_every() facilite grandement la création de tâches répétitives avec Asyncio.
Voici un extrait du code d’un objet dans lequel est déclaré la fonction run_every(). Cette fonction met en œuvre une boucle infinie dont la seule tâche est d’appeler une fonction de traitement nommée fn à intervalle régulier. La fonction de traitement fn est communiquée en paramètre, cette dernière implémente le code utile de la tâche à exécuter à intervalle régulier.
À noter que la fonction run_every() éteint la LED sur la broche #0 pendant l’exécution de la fonction fn. Cela permet d’informer un éventuel utilisateur qu’une tâche est en cours d’exécution.
01 : async def run_every( fn, min= 1, sec=None):
02: """ Exécute une fonction fn toutes les minutes ou
secondes"""
03: global led
04: wait_sec = sec if sec else min*60
05: while True:
06: led.value( 1 )
07: try:
08: fn()
09: except Exception:
10: print( "run_every catch exception for %s" % fn)
11: raise # quitter boucle
12: led.value( 0 )
13: await asyncio.sleep( wait_sec )
Ligne 1 : définition de la fonction. Le paramètre fn contient une référence vers la fonction à appeler à intervalle régulier. Le paramètre min indique le temps de pause en minutes entre deux appels de la fonction fn. Si défini, le paramètre sec indique le temps de pause en seconde et sera prévalent sur le paramètre min.
Ligne 3 : permet d’avoir accès à la variable globale led. Cette dernière permet de contrôler la LED branchée sur la broche #0 du Feather ESP8266.
Ligne 4 : calcul du temps de pause en secondes depuis le paramètre sec si il est défini, sinon c’est le paramètre min (minutes) qui sera utilisé.
Ligne 5 : boucle infinie.
Ligne 6 : éteint la LED (logique inverse).
Ligne 8 : exécution de la fonction indiquée par le paramètre fn. Les parenthèses derrière fn() permettent d’exécuter la fonction en question. La fonction est appelée sans paramètre.
Lignes 7-9-11 : capture de toutes les exceptions durant l’exécution de la fonction fn. La ligne 10 affiche une représentation de la fonction (le nom de la fonction défini dans le script) ayant produit l’erreur de sorte que cette information est visible sur une console REPL. La ligne 11 relance l’exception d’origine pour qu’elle atteigne la boucle de traitement asyncio.
Ligne 12 : rallume la LED branchée sur la broche #0 après exécution de la fonction de rappel fn.
Ligne 13 : temps de pause entre deux appels consécutifs de fn.
Lors de la création d’une tâche, la fonction run_every() s’utilise comme suit :
01: loop = asyncio.get_event_loop()
02: loop.create_task( run_every(capture_1h, min=60) )
03: loop.create_task( run_every(pir_alert, sec=10) )
04: loop.create_task( run_every(pir_update, min=5))
05: loop.create_task( run_every(heartbeat, sec=10) )
06: try:
07: loop.run_until_complete( run_app_exit() )
08: except Exception as e :
09: print( e )
10: led_error( step=6 )
Ligne 1 : création de la boucle de traitement asyncio.
Ligne 2 : création d’une tâche répétitive (toutes les 60 minutes) qui appelle la fonction capture_1h(). Cette fonction, exécutée toutes les heures, est responsable de la capture de paramètres environnementaux et de leur publication sur le broker MQTT.
Lignes 3-5 : création d’autres tâches répétitives appelant d’autres fonctions.
Ligne 7 : exécution de la boucle de traitement asyncio jusqu’à la fin d’exécution de la fonction run_app_exit(). La fonction run_app_exit() (détaillée ci-dessous) teste l’état de l’interrupteur RunApp (broche #12) toutes les 10 secondes. La fonction termine son traitement lorsque RunApp est placé sur la position arrêt.
Lignes 6-8-10 : capture de toutes les exceptions pouvant se produire durant le traitement des tâches asynchrones. La ligne 9 affiche le message d’exception dans une session REPL. La ligne 10 appelle la fonction led_error() responsable de l’affichage d’un code d’erreur sur la LED de la broche #0 et du redémarrage de l’objet (cf. Les objets ESP8266 - LED de statut).
Détails de la fonction run_app_exit() :
01: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
02:
03: async def run_app_exit():
04: """ fin d’exécution lorsque quitte la fonction """
05: global runapp
06: while runapp.value()==1:
07: await asyncio.sleep( 10 )
08: return
L’utilisation de Asyncio va bien au-delà de la création d’un planificateur de tâche, ces fonctionnalités sortent du cadre de l’ouvrage.
De nombreuses informations et exemples concernant Asyncio pour MicroPython sont disponibles sur le dépôt GitHub : https://github.com/peterhinch/micropython-async
Ce chapitre se concentre sur le développement des objets IoT et nécessite quelques prérequis abordés dans les précédents chapitres avant de se lancer dans l’aventure.
1. | Les ESP8266 sont reflashés avec MicroPython pour ESP8266 (version 1.9.1 minimum, elle apporte le support de uasyncio utilisé dans les objets), cf. ESP8266 sous MicroPython - Charger le firmware MicroPython. |
2. | La copie de scripts Python sur l’ESP8266 à l’aide d’un utilitaire comme RShell (ou équivalent) est un point maîtrisé, cf. ESP8266 sous MicroPython - Prise de contrôle. |
3. | La mise en place du fichier boot.py avec authentification sur le réseau Wi-Fi domestique ainsi que la fonctionnalité RunApp, cf. ESP8266 sous MicroPython - Séquence de démarrage MicroPython. |
4. | Le broker MQTT Eclipse Mosquitto est installé sur le Raspberry Pi et configuré avec une authentification avec le login pusr103 et le mot de passe 21052017, cf. Le broker MQTT - Installation de Mosquitto, cf. Le broker MQTT - Configurer le login du broker MQTT. |
5. | Le Raspberry Pi exécutant le broker MQTT est configuré avec l’adresse IP fixe 192.168.1.210 dans le cadre de cet ouvrage. Toute modification d’adresse IP du Raspberry Pi implique une modification des scripts Python avant de les téléverser sur les objets. |
Un élément important de tout projet est la possibilité d’informer l’utilisateur sur son état de fonctionnement. La LED #0 disponible sur le feather ESP8266 est utilisée pour indiquer ce statut.
Utilisation de la LED #0 comme LED de statut
La LED de statut utilise plusieurs motifs de clignotement pour informer l’utilisateur sur le fonctionnement interne de la plateforme.
Motif de la LED | Description |
Éteinte | À l’arrêt. Vérifier interrupteur RunApp (ou le fichier boot.py) puis presser Reset pour redémarrer. À noter que la LED est également éteinte après l’arrêt de l’objet. |
Allumée (après démarrage) | Début d’exécution (dans le script main.py, juste après le test RunApp). |
Heartbeat | Allumée fixe avec une extinction de 200 ms toutes les 10 secondes. En cours de fonctionnement. |
Erreur | Successions de clignotements rapides entrecoupés de séquences de clignotements lents. Le nombre de clignotements lents (de 1 à 6) indique un code d’erreur. En cas d’erreur, l’ESP8266 est redémarré automatiquement (machine.reset()) après une heure. Voir détail des codes d’erreurs ci-dessous. |
Erreur 1 | MQTTClient retourne un code d’erreur, code renvoyé par le broker MQTT. |
Erreur 2 | Erreur lors de la connexion MQTT. Vérifier les constantes MQTT_SERVER, MQTT_USE, MQTT_PSWD. Le contenu du message d’exception est renvoyé sur la console REPL. |
Erreur 3 | Erreur durant le chargement des bibliothèques senseurs. Vérifier la présence des différentes bibliothèques nécessaires et le fait qu’elles se chargent en mémoire sans erreur (par ex. en chargeant la bibliothèque dans une session REPL). Le contenu du message d’exception est renvoyé sur la console REPL. |
Erreur 4 | Erreur durant la création des objets destinés à la lecture des différents senseurs. Le contenu du message d’exception est renvoyé sur la console REPL. |
Erreur 5 | Erreur durant la publication de l’adresse MAC sur le topic connect/<clientID> lors du démarrage de l’objet. Le contenu du message d’exception est renvoyé sur la console REPL. |
Erreur 6 | Erreur durant le traitement des tâches (capture de données sur les senseurs et publication MQTT). Le contenu du message d’exception est renvoyé sur la console REPL. |
Les topics MQTT et publications des informations sont détaillés dans le chapitre de la mise en place du broker MQTT, cf. Le broker MQTT - Topics du projet.
Le code source des différents objets IoT est disponible sur le dépôt GitHub du projet : https://github.com/mchobby/la-maison-pythonic
Une copie des sources est également disponible dans les ressources de cet ouvrage.
Les sources peuvent être facilement téléchargées sur le Raspberry Pi à l’aide de l’utilitaire git.
cd ~
git clone https://github.com/mchobby/la-maison-pythonic.git
Un nouveau répertoire la-maison-pythonic est disponible dans le répertoire utilisateur (/home/pi). Ce dernier contient les sources des différents objets dans le sous-répertoire esp8266. Le répertoire esp8266 contient lui-même un sous-répertoire par objet.
Par exemple, le code destiné à la cabane est disponible dans le répertoire /home/pi/la-maison-pythonic/esp8266/cabane/.
Code source de l’objet Cabane tel que disponible sur GitHub
Le script principal est bien entendu main.py, mais d’autres versions sont également disponibles comme test.py permettant de tester les senseurs et main_simple.py proposant une version intermédiaire, mais simplifiée, du code.
Bootstrap.sh
Le fichier bootstrap.sh permet de télécharger les dépendances (les bibliothèques Python) de l’objet cabane. Les bibliothèques des senseurs sont publiées sur un autre dépôt GitHub, le fichier bootstrap.sh fait le nécessaire pour les rapatrier dans le répertoire courant. Ces fichiers devront aussi être copiés sur l’ESP8266.
Contenu du fichier bootstrap de l’objet cabane
Pour télécharger les dépendances :
1. | Ouvrir un terminal. |
2. | Se placer dans le répertoire cabane. |
3. | Exécuter le script boostrap.sh. |
Si le dépôt GitHub a été cloné dans le répertoire utilisateur alors les dépendances (bibliothèques) peuvent être téléchargées comme suit :
cd ~/la-maison-pythonic/esp8266/cabane/
./bootstrap.sh
Téléchargement des bibliothèques
Tous les scripts des différents objets développés dans ce chapitre suivent une même structure de code.
Le code ci-dessous reprend la structure générale d’un objet.
01: # coding: utf8
02: """ La Maison Pythonic - Object Cabane v0.2
03:
04: Envoi des données toutes les heures + 30 minutes
05: vers serveur MQTT
06: """
07:
08: from machine import Pin, I2C, reset
09: from time import sleep, time
10: from ubinascii import hexlify
11: from network import WLAN
12:
13: CLIENT_ID = ’cabane’
14: MQTT_SERVER = "192.168.1.210"
15:
16: # Mettre à None si pas utile
17: MQTT_USER = ’pusr103’
18: MQTT_PSWD = ’21052017’
19:
20: # redémarrage auto après erreur
21: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec
22:
23: # --- Démarrage conditionnel ---
24: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
25: led = Pin( 0, Pin.OUT )
26: led.value( 1 ) # éteindre
27:
28: def led_error( step ):
29: global led
30: t = time()
31: while ( time()-t ) < ERROR_REBOOT_TIME:
32: for i in range( 20 ):
33: led.value(not(led.value()))
34: sleep(0.100)
35: led.value( 1 ) # éteindre
36: sleep( 1 )
37: # clignote nbr fois
38: for i in range( step ):
39: led.value( 0 )
40: sleep( 0.5 )
41: led.value( 1 )
42: sleep( 0.5 )
43: sleep( 1 )
44: # Re-start the ESP
45: reset()
46:
47: if runapp.value() != 1:
48: from sys import exit
49: exit(0)
50:
51: led.value( 0 ) # allumer
52:
53: # --- Programme Principal ---
54: from umqtt.simple import MQTTClient
55: try:
56: q = MQTTClient( client_id = CLIENT_ID,
57: server = MQTT_SERVER,
58: user = MQTT_USER,
59: password = MQTT_PSWD )
60: if q.connect() != 0:
61: led_error( step=1 )
62: except Exception as e:
63: print( e )
64: # vérifier MQTT_SERVER, MQTT_USER, MQTT_PSWD
65: led_error( step=2 )
66:
67: try:
68: # Importation des bibliothèques
69: ...
70: except Exception as e:
71: print( e )
72: led_error( step=3 )
73:
74: # déclare les bus
75: i2c = I2C( sda=Pin(4), scl=Pin(5) )
76:
77: # créer les senseurs
78: try:
79: # Création des senseurs
80: ...
81: except Exception as e:
82: print( e )
83: led_error( step=4 )
84:
85: try:
86: # annonce connexion objet
87: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
88: q.publish( "connect/%s" % CLIENT_ID , sMac )
89: except Exception as e:
90: print( e )
91: led_error( step=5 )
92:
93: import uasyncio as asyncio
94:
95: def capture_1h():
96: """ Exécuté pour capturer des
97: données chaque heure """
98: global q
99: ...autres global
100:
101: # Lecture senseur
102: ...
103:
104: # Publication
105: q.publish( "maison/temp", t )
106: ...
107:
108: def heartbeat():
109: """ Led éteinte 200ms toutes les 10 sec """
110: sleep( 0.2 )
111:
112: async def run_every( fn, min= 1, sec=None):
113: """ Execute a function fn every min
114: minutes or sec secondes"""
115: global led
116: wait_sec = sec if sec else min*60
117: while True:
118: # éteindre LED pendant envoi/traitement
119: led.value( 1 )
120: fn()
121: led.value( 0 ) # allumer
122: await asyncio.sleep( wait_sec )
123:
124: async def run_app_exit():
125: """ fin d’exécution lorsque
126: la fonction quitte """
127: global runapp
128: while runapp.value()==1:
129: await asyncio.sleep( 10 )
130: return
131:
132: loop = asyncio.get_event_loop()
133: loop.create_task( run_every(capture_1h, min=60) )
134: ...
135: loop.create_task( run_every(heartbeat, sec=10) )
136: try:
137: loop.run_until_complete( run_app_exit() )
138: except Exception as e :
139: print( e )
140: led_error( step=6 )
141:
142: loop.close()
143: led.value( 1 ) # eteindre
144: print( "Fin!")
La section principale du script débute en ligne 53. Les lignes en deçà définissent des constantes et fonctions utilitaires.
À partir de la ligne 53, le script est scindé en sections protégées par des blocs try...except.
En cas d’erreur, chacun de ces blocs try...except :
•.affiche l’exception dans la session REPL avec print(e),
•.fait appel à la fonction led_error().
Led_error() fournit une indication visuelle de l’erreur et prend en charge le redémarrage automatique de l’objet.
La liste ci-dessous énumère les différentes sections de code. En respectant scrupuleusement cette découpe, il est possible de savoir quelle partie du programme provoque l’erreur.
•.Lignes 54-65 : section de connexion sur le broker MQTT. Concerne les codes d’erreurs 1 et 2.
•.Lignes 67-72 : section d’importation des bibliothèques senseurs. Si une bibliothèque ne peut pas être chargée, alors cela produit le code d’erreur 3.
•.Lignes 74-75 : création des bus de communication I2C ou SPI. Les différents objets utilisent principalement le bus I2C. Cette section n’est pas protégée étant donné qu’elle n’est pas supposée produire d’erreur.
•.Lignes 77-83 : section de création des senseurs et autres objets. En cas d’erreur de création, cette section produit le code d’erreur 4.
•.Lignes 85-91 : section d’annonce de connexion de l’objet sur le broker MQTT. Si la publication est impossible, alors cela produit le code d’erreur 5.
•.Lignes 93-130 : définition des fonctions de publication de données et des fonctions asynchrones.
•.Lignes 132-135 : création des tâches asynchrones.
•.Lignes 136-140 : section d’exécution des tâches asynchrones. Toute erreur produite par l’une des fonctions asynchrones ou une tâche est capturée par le bloc try...except auquel cas, cela produit un code d’erreur 6.
•.Ligne 142 : fin de traitement et extinction de la LED.
Les paramètres du script sont regroupés entre les lignes 13 et 21 avec la déclaration des constantes suivantes :
CLIENT_ID : identification du client se connectant sur le broker MQTT.
MQTT_SERVER : identification du broker MQTT. Soit l’adresse IP, soit un nom de domaine pour un serveur en ligne. La résolution mDns n’étant pas prise en charge par MicroPython, il n’est pas recommandé d’utiliser une valeur telle que ’pythonic.local’. Voir la note au sujet de la résolution DNS dans le chapitre ESP8266 sous MicroPython - MQTT sous ESP8266.
MQTT_USER : compte utilisateur sur le broker MQTT. Utiliser la valeur None pour une connexion anonyme.
MQTT_PSWD : le mot de passe correspondant au compte utilisateur. None pour une connexion anonyme.
ERROR_REBOOT_TIME : temps au terme duquel l’objet est automatiquement redémarré lorsqu’une erreur s’est produite. 3 600 secondes par défaut (1h).
La LED d’activité est branchée sur la broche 0 et fonctionne en logique inverse (cf. ESP8266 sous MicroPython - Prise de contrôle).
La variable led déclarée en ligne 25 permet de commander cette LED. Elle est immédiatement éteinte en ligne 26 avec l’instruction led.value( 1 ).
L’interrupteur RunApp est branché sur la broche #12, il permet d’arrêter le fonctionnement de l’objet (cf. ESP8266 sous MicroPython - Programmer).
La variable runapp déclarée en ligne 24 permet de lire l’état de l’interrupteur.
Le test if runapp.value()!= 1 en ligne 47 permet de vérifier l’état de l’interrupteur. Si ce dernier est fermé, l’instruction exit(0) permet d’interrompre le fonctionnement du script.
La fonction led_error( step ) est appelée pour signaler une erreur.
L’erreur est signalée par une dizaine de clignotements rapides (intervalle de 100 ms) suivis d’un certain nombre de clignotements lents (intervalle de 1/2 seconde). Le nombre de clignotements lents (paramètre step) indique le code d’erreur.
28: def led_error( step ):
29: global led
30: t = time()
31: while ( time()-t ) < ERROR_REBOOT_TIME:
32: for i in range( 20 ):
33: led.value(not(led.value()))
34: sleep(0.100)
35: led.value( 1 ) # éteindre
36: sleep( 1 )
37: # clignote nbr fois
38: for i in range( step ):
39: led.value( 0 )
40: sleep( 0.5 )
41: led.value( 1 )
42: sleep( 0.5 )
43: sleep( 1 )
44: # redémarrer l’ESP
45: reset()
La fonction led_error( step ) se comporte comme un piège répétant indéfiniment la séquence d’erreur. Au terme d’un délai d’attente défini dans ERROR_REBOOT_TIME, le microcontrôleur est redémarré. Ce redémarrage permet de faire une nouvelle tentative de connexion sur le broker MQTT, commode si l’erreur est produite par une interruption des communications.
Ligne 29 : récupération de la variable globale led permettant de commander la LED.
Ligne 30 : capture du temps lors de l’activation de la fonction.
Ligne 31 : répéter la séquence d’erreur pendant ERROR_REBOOT_TIME secondes (soit 3 600 secondes, une heure).
Lignes 32-34 : faire clignoter rapidement la LED 10 fois (soit 20 changements d’état).
Lignes 35-36 : éteindre la LED et attendre 1 seconde avant d’envoyer le code d’erreur.
Lignes 38 -42 : afficher le code d’erreur en faisant clignoter step fois la LED.
Ligne 43 : attendre une seconde avant de redémarrer la séquence d’erreur.
Ligne 45 : l’instruction reset() exécutée au terme des ERROR_REBOOT_TIME secondes redémarre l’ESP8266.
Le support d’Asyncio sur ESP8266 et de la fonction run_every() a été traité dans le chapitre ESP8266 sous MicroPython à la section Asyncio sur ESP8266.
Pour rappel, la LED est éteinte par la fonction run_every() pendant que celle-ci exécute une tâche comme, par exemple, la fonction capture_1h().
Tâche de capture
La tâche capture_1h() est appelée toutes les heures. Cet appel est configuré à la ligne 133 par l’instruction loop.create_task( run_every(capture_1h, min=60) ).
Voici le code que l’on retrouve typiquement dans une tâche.
95: def capture_1h():
96: """ Exécuté pour capturer des
97: données chaque heure """
98: global q
99: ...autres global
100:
101: # Lecture senseur
102: ...
103:
104: # Publication
105: q.publish( "maison/temp", t )
106: ...
Ligne 98 : récupération de la variable globale q. Celle-ci offre un accès à l’instance du MQTTClient connecté sur le broker MQTT (voir ligne 56). Cette référence permettra de publier des messages sur le broker.
Ligne 99 : importation des autres variables globales correspondant aux senseurs (créés en lignes 79-80).
Ligne 102 : endroit où les senseurs sont interrogés et les données préparées pour une future publication. Par exemple : le relevé de la température et la constitution d’un message t de type str contenant la valeur de la température sous forme d’une chaîne de caractères.
Ligne 105 : exemple de publication de la température sur le topic maison/temp du broker MQTT.
La tâche heartbeat()
La tache heartbeat() éteint la LED pendant 200 ms toutes les 10 secondes. Cette fonction signale simplement que la boucle de traitement asyncio est toujours en cours d’exécution et donc que l’objet est toujours en cours d’exécution.
La tâche heartbeat() est configurée avec l’instruction loop.create_task( run_ every(heartbeat, sec=10) ) en ligne 135.
108: def heartbeat():
109: """ Led éteinte 200ms toutes les 10 sec """
110: sleep( 0.2 )
Pour rappel, run_every() éteint la LED pendant l’exécution d’une tâche. Par conséquent, lorsque heartbeat() est appelé, la LED est déjà éteinte.
La fonction heartbeat() doit seulement attendre 200 ms au terme desquelles la LED est rallumée par run_every().
La fonction asynchrone run_app_exit()
La fonction asynchrone run_app_exit() est utilisée pour quitter la boucle d’exécution Asyncio.
124: async def run_app_exit():
125: """ fin d’exécution lorsque quitte
126: la fonction """
127: global runapp
128: while runapp.value()==1:
129: await asyncio.sleep( 10 )
130: return
131:
132: loop = asyncio.get_event_loop()
133: loop.create_task( run_every(capture_1h, min=60) )
134: ...
135: loop.create_task( run_every(heartbeat, sec=10) )
136: try:
137: loop.run_until_complete( run_app_exit() )
138: except Exception as e :
139: print( e )
140: led_error( step=6 )
141:
142: loop.close()
143: led.value( 1 ) # éteindre
144: print( "Fin!")
L’instruction loop.run_until_complete( run_app_exit() ) exécute la boucle de traitement et les différentes tâches asynchrones (lignes 133 à 135) en précisant une condition de sortie via run_app_exit().
La boucle de traitement asynchrone est interrompue lorsque la fonction asynchrone run_app_exit() termine son exécution.
La fonction asynchrone run_app_exit() fonctionne comme suit :
•.Ligne 127 : accès à la variable globale runapp qui permet de lire l’état de la broche 12 sur laquelle l’interrupteur est branché.
•.Ligne 128 : boucle while qui s’exécute aussi longtemps que l’interrupteur est ouvert (la résistance pull-up interne maintient la broche au niveau haut).
•.Ligne 129 : attend 10 secondes entre deux tests. L’instruction await asyncio.sleep( 10 ) permet d’instaurer le temps de pause en rendant la main à la boucle de traitement Asyncio. De la sorte, le traitement n’est pas bloquant et les différentes autres tâches peuvent également être exécutées.
À la lecture des informations ci-dessus, il est facile de comprendre que la modification de la position de l’interrupteur RunApp ne prend effet que dans un délai de 0 à 10 secondes. Délai tout à fait correct pour une interruption de fonctionnement occasionnelle.
À noter que :
•.La LED est éteinte pour signaler la fin de fonctionnement.
•.La connexion MQTT n’est pas volontairement interrompue, ce qui peut permettre différentes investigations à l’aide d’une session REPL.
Le schéma suivant présente le montage réalisé pour la cabane de jardin. Celui-ci reprend trois senseurs (AM2315, BMP280 et TSL2561) sur le bus I2C du feather ESP8266. Le raccordement et le test individuel de ces senseurs est abordé dans le chapitre ESP8266 sous MicroPython.
L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est également abordé en détail dans le chapitre ESP8266 sous MicroPython.
Objet IoT de la cabane de jardin
Le script est disponible dans le répertoire esp8266/cabane/main.py du dépôt GitHub de l’ouvrage.
Le fichier bootstrap.sh permet de télécharger les bibliothèques utilisées par le script main.py dans le répertoire courant (à savoir am2315.py, bme280.py, tsl2561.py). Bootstrap.sh est un script shell qui peut être exécuté dans une ligne de commande du Raspberry Pi.
Les fichiers disponibles sur le dépôt GitHub du projet
Après avoir modifié le fichier main.py pour y fixer les paramètres correspondant à la configuration actuelle, téléchargez le fichier main.py et les bibliothèques de dépendances sur l’ESP8266.
Ci-dessous les paramètres à adapter dans le fichier main.py.
MQTT_SERVER = "192.168.1.210"
# Mettre à None si pas utile
MQTT_USER = ’pusr103’
MQTT_PSWD = ’21052017’
Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :
•.main.py - script principal.
•.boot.py - fichier de démarrage. Établit la connexion Wi-Fi.
•.am2315.py, bme280.py, tsl2561.py- bibliothèques utilisées par le script.
Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :
•.1 relevé de température et de pression atmosphérique toutes les 20 minutes. Pris en charge par le BMP280.
•.1 relevé de luminosité en Lux toutes les heures. Pris en charge par le TSL2561.
•.1 relevé de température et d’humidité du jardin toutes les heures. Pris en charge par l’AM2315.
01: # coding: utf8
02: """ La Maison Pythonic - Object Cabane v0.2
03:
04: Envoi des données
05: vers serveur MQTT
06: """
07:
08: from machine import Pin, I2C, reset
09: from time import sleep, time
10: from ubinascii import hexlify
11: from network import WLAN
12:
13: CLIENT_ID = ’cabane’
14:
15: # Utiliser résolution DNS (serveur en ligne)
16: # MQTT_SERVER = ’test.mosquitto.org’
17: #
18: # Utiliser IP si le Pi en adresse fixe
19: # (plus fiable sur réseau local/domestique)
20: # MQTT_SERVER = ’192.168.1.220’
21: #
22: # Utiliser le hostname si Pi en DHCP et que la propagation
23: # du hostname atteint le modem/router (voir aussi gestion
24: # mDns sur router).
25: # (pas forcément fiable sur réseau domestique)
26: # MQTT_SERVER = ’pythonic’
27: #
28: # Attention: MicroPython sous ESP8266 ne gère pas mDns!
29:
30: MQTT_SERVER = "192.168.1.210"
31:
32: # Mettre à None si pas utile
33: MQTT_USER = ’pusr103’
34: MQTT_PSWD = ’21052017’
35:
36: # redémarrage auto après erreur
37: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec
38:
39: # --- Démarrage conditionnel ---
40: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
41: led = Pin( 0, Pin.OUT )
42: led.value( 1 ) # éteindre
43:
44: def led_error( step ):
45: global led
46: t = time()
47: while ( time()-t ) < ERROR_REBOOT_TIME:
48: for i in range( 20 ):
49: led.value(not(led.value()))
50: sleep(0.100)
51: led.value( 1 ) # éteindre
52: sleep( 1 )
53: # clignote nbr fois
54: for i in range( step ):
55: led.value( 0 )
56: sleep( 0.5 )
57: led.value( 1 )
58: sleep( 0.5 )
59: sleep( 1 )
60: # Re-start the ESP
61: reset()
62:
63: if runapp.value() != 1:
64: from sys import exit
65: exit(0)
66:
67: led.value( 0 ) # allumer
68:
69: # --- Programme Pincipal ---
70: from umqtt.simple import MQTTClient
71: try:
72: q = MQTTClient( client_id = CLIENT_ID, server =
73: MQTT_SERVER, user = MQTT_USER, password =
74: MQTT_PSWD )
75: if q.connect() != 0:
76: led_error( step=1 )
77: except Exception as e:
78: print( e )
79: # check MQTT_SERVER, MQTT_USER, MQTT_PSWD
80: led_error( step=2 )
81:
82: try:
83: from tsl2561 import TSL2561
84: from bme280 import BME280, BMP280_I2CADDR
85: from am2315 import AM2315
86: except Exception as e:
87: print( e )
88: led_error( step=3 )
89:
90: # declare le bus i2c
91: i2c = I2C( sda=Pin(4), scl=Pin(5) )
92:
93: # créer les senseurs
94: try:
95: tsl = TSL2561( i2c=i2c )
96: bmp = BME280( i2c=i2c, address=BMP280_I2CADDR )
97: am = AM2315( i2c=i2c )
98: except Exception as e:
99: print( e )
100: led_error( step=4 )
101:
102: try:
103: # annonce connexion objet
104: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
105: q.publish( "connect/%s" % CLIENT_ID , sMac )
106: except Exception as e:
107: print( e )
108: led_error( step=5 )
109:
110: import uasyncio as asyncio
111:
112: def capture_1h():
113: """ Exécuté pour capturer des données chaque heure """
114: global q
115: global tsl
116: global am
117: # tsl2561 - senseur lux
118: lux = "{0:.2f}".format( tsl.read() )
119: q.publish( "maison/exterieur/cabane/lux", lux )
120: # am2315 - humidité/température
121: am.measure() # réactive le senseur
122: sleep( 1 )
123: am.measure()
124: t = "{0:.2f}".format( am.temperature() )
125: h = "{0:.2f}".format( am.humidity() )
126: q.publish( "maison/exterieur/jardin/temp", t )
127: q.publish( "maison/exterieur/jardin/hrel", h )
128:
129: def capture_20min():
130: """ Exécuté pour capturer des données chaque 20 minutes """
131: global q
132: global bmp
133: # bmp280 - senseur pression/température
134: # capturer les valeurs sous format texte
135: (t,p,h) = bmp.raw_values
136: # transformer en chaines de caractère
137: t = "{0:.2f}".format(t)
138: p = "{0:.2f}".format(p)
139: q.publish( "maison/exterieur/cabane/pathm", p )
140: q.publish( "maison/exterieur/cabane/temp", t )
141:
142: def heartbeat():
143: """ Led éteinte 200ms toutes les 10 sec """
144: sleep( 0.2 )
145:
146: async def run_every( fn, min= 1, sec=None):
147: """ Exécute une fonction fn toutes les minutes ou
148: secondes"""
149: global led
150: wait_sec = sec if sec else min*60
151: while True:
152: # éteindre pendant envoi/traitement
153: led.value( 1 )
154: fn()
155: led.value( 0 ) # allumer
156: await asyncio.sleep( wait_sec )
157:
158: async def run_app_exit():
159: """ fin d’exécution lorsque la fonction quitte """
160: global runapp
161: while runapp.value()==1:
162: await asyncio.sleep( 10 )
163: return
164:
165: loop = asyncio.get_event_loop()
166: loop.create_task( run_every(capture_1h, min=60) )
167: loop.create_task( run_every(capture_20min, min=20) )
168: loop.create_task( run_every(heartbeat, sec=10) )
169: try:
170: loop.run_until_complete( run_app_exit() )
171: except Exception as e :
172: print( e )
173: led_error( step=6 )
174:
175: loop.close()
176: led.value( 1 ) # éteindre
177: print( "Fin!")
Le fonctionnement général de l’objet étant décrit ci-avant, cette section se concentre sur les éléments clés du script.
Les senseurs sont créés entre les lignes 95 et 97.
95: tsl = TSL2561( i2c=i2c )
96: bmp = BME280( i2c=i2c, address=BMP280_I2CADDR )
97: am = AM2315( i2c=i2c )
Le script prévoit deux tâches capture_1h() et capture_20min() pour répondre aux spécifications.
166: loop.create_task( run_every(capture_1h, min=60) )
167: loop.create_task( run_every(capture_20min, min=20) )
La fonction capture_1h
La fonction capture_1h() publie les données des senseurs TSL2561 et AM2315 toutes les heures.
112: def capture_1h():
113: """ Exécuté pour capturer des données chaque heure """
114: global q
115: global tsl
116: global am
117: # tsl2561 - senseur lux
118: lux = "{0:.2f}".format( tsl.read() )
119: q.publish( "maison/exterieur/cabane/lux", lux )
120: # am2315 - humidité/température
121: am.measure() # réactive le senseur
122: sleep( 1 )
123: am.measure()
124: t = "{0:.2f}".format( am.temperature() )
125: h = "{0:.2f}".format( am.humidity() )
126: q.publish( "maison/exterieur/jardin/temp", t )
127: q.publish( "maison/exterieur/jardin/hrel", h )
Lignes 114 à 116 : récupération des variables globales correspondant respectivement au client MQTT, senseur TSL2561 et senseur AM2315.
Ligne 118 : capture de la valeur en Lux avec tsl.read() qui retourne une valeur numérique. La valeur est convertie en chaîne de caractères avec deux valeurs décimales en utilisant la méthode format(). En effet, "{0:.2f}".format( 1.9187 ) produit le résultat ’1.92’.
Ligne 119 : publication de la luminosité sur le topic maison/exterieur/cabane/lux.
Lignes 121 à 123 : le premier appel de am.measure() réactive le senseur et renvoie immédiatement la dernière valeur échantillonnée une heure plus tôt et gardée en mémoire. La deuxième lecture effectue un nouvel échantillonnage.
Lignes 124 à 125 : transformation des valeurs de température et d’humidité relative en chaînes de caractères.
Lignes 126 à 127 : publication de la température et de l’humidité du jardin sur le broker MQTT.
La fonction capture_20min
La fonction capture_20m() publie les données du senseur BMP280 toutes les 20 minutes. Cette fréquence de capture est suffisante pour permettre à un système distant d’envisager une analyse de prévision météo sommaire à partir de l’historique des données collectées.
129: def capture_20min():
130: """ Exécuté pour capturer des données chaque 20 minutes """
131: global q
132: global bmp
133: # bmp280 - senseur pression/température
134: # capturer les valeurs sous format texte
135: (t,p,h) = bmp.raw_values
136: # transformer en chaines de caractères
137: t = "{0:.2f}".format(t)
138: p = "{0:.2f}".format(p)
139: q.publish( "maison/exterieur/cabane/pathm", p )
140: q.publish( "maison/exterieur/cabane/temp", t )
Lignes 131 et 132 : récupération des variables globales du client MQTT et du senseur BMP280.
Ligne 135 : capture des données du senseur BMP280 sous la forme d’un tuple de trois valeurs (température, pression, humidité). La bibliothèque étant également prévue pour le senseur BME280, la propriété raw_values retourne une 3e valeur correspondant à l’humidité relative. Dans le cas d’un BMP280, l’humidité relative est systématiquement à 0. L’instruction (t,p,h) = bmp.raw_values permet d’assigner, en une seule opération, les trois valeurs respectivement aux variables t, p et h.
Ligne 137 : conversion de la valeur numérique de la température en chaîne de caractères avec deux valeurs décimales. En effet, "{0:.2f}".format( 25.4 ) produit le résultat ’25.40’.
Ligne 138 : conversion de la pression atmosphérique en chaîne de caractères.
Ligne 139 : publication de la pression atmosphérique sur le topic maison/exterieur/cabane/pathm.
Ligne 140 : publication de la température.
Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :
mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.
Les messages suivants apparaissent après la mise-sous-tension de l’objet.
pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
connect/cabane 5ccf7fc6d4e3
maison/exterieur/cabane/lux 3.94
maison/exterieur/jardin/temp 20.60
maison/exterieur/jardin/hrel 48.10
maison/exterieur/cabane/pathm 989.10
maison/exterieur/cabane/temp 19.71
Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.
Le schéma suivant présente le montage réalisé pour la surveillance du salon. Celui-ci reprend un senseur de température analogique (TMP36) associé à un breakout de conversion analogique/numérique ADS1115. Le senseur PIR permet de réaliser une détection de présence avec une annonce répétitive en cas de présence.
L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est abordé en détail dans le chapitre ESP8266 sous MicroPython.
Objet IoT du salon
Le script est disponible dans le répertoire esp8266/salon/main.py du dépôt GitHub de l’ouvrage.
Le fichier bootstrap.sh permet de télécharger les bibliothèques nécessaires au bon fonctionnement de main.py (à savoir ads1x15.py). Le script shell bootstrap.sh peut être exécuté depuis une ligne de commande sur le Raspberry Pi.
Le fichier main.py doit être modifié pour fixer les paramètres correspondant à la configuration actuelle.
Ci-dessous les paramètres à adapter dans le fichier main.py.
MQTT_SERVER = "192.168.1.210"
# Mettre a None si pas utile
MQTT_USER = ’pusr103’
MQTT_PSWD = ’21052017’
Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :
•.main.py - script principal.
•.boot.py - fichier de démarrage. Établit la connexion Wi-Fi.
•.ads1x15.py - bibliothèque utilisée par le script.
Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :
•.1 relevé de température toutes les heures. Pris en charge par le TMP36 et l’ADS1115.
•.Envoi du message « MOUV » en cas d’activation du senseur PIR. Répétition du message « MOUV » toutes les 15 minutes si l’activité se poursuit. Envoi unique de « NONE » si l’activité a cessé depuis 15 minutes.
01: # coding: utf8
02: """ La Maison Pythonic - Object Salon v0.1
03:
04: Envoi des données température et senseur PIR vers
05: serveur MQTT
06: """
07:
08: from machine import Pin, I2C, reset
09: import time
10: from ubinascii import hexlify
11: from network import WLAN
12:
13: CLIENT_ID = ’salon’
14:
15: # Utiliser la résolution DNS (serveur en ligne)
16: # MQTT_SERVER = ’test.mosquitto.org’
17: #
18: # Utiliser IP si le Pi en adresse fixe
19: # (plus fiable sur réseau local/domestique)
20: # MQTT_SERVER = ’192.168.1.220’
21: #
22: # Utiliser le hostname si Pi en DHCP et que la propagation
23: # du
24: # hostname atteint le modem/routeur (voir aussi gestion mDns
25: # sur routeur).
26: # (pas forcément fiable sur réseau domestique)
27: # MQTT_SERVER = ’pythonic’
28: #
29: # Attention: MicroPython sous ESP8266 ne gère pas mDns!
30:
31: MQTT_SERVER = "192.168.1.210"
32:
33: # Mettre à None si pas utile
34: MQTT_USER = ’pusr103’
35: MQTT_PSWD = ’21052017’
36:
37: # redémarrage auto après erreur
38: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec
39:
40: # PIR
41: PIR_PIN = 13 # Signal du senseur PIR.
42: PIR_RETRIGGER_TIME = 15 * 60 # 15 min
43: # temps (sec) dernière activation PIR
44: last_pir_time = 0
45: last_pir_msg = "NONE"
46: # temps (sec) dernier envoi MSG
47: last_pir_msg_time = 0
48: # Programme principal doit-il envoyer
49: # une notification "MOUV" rapidement?
50: fire_pir_alert = False
51:
52: # --- Démarrage conditionnel ---
53: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
54: led = Pin( 0, Pin.OUT )
55: led.value( 1 ) # éteindre
56:
57: def led_error( step ):
58: global led
59: t = time.time()
60: while ( time.time()-t ) < ERROR_REBOOT_TIME:
61: for i in range( 20 ):
62: led.value(not(led.value()))
63: time.sleep(0.100)
64: led.value( 1 ) # éteindre
65: time.sleep( 1 )
66: # clignote nbr fois
67: for i in range( step ):
68: led.value( 0 )
69: time.sleep( 0.5 )
70: led.value( 1 )
71: time.sleep( 0.5 )
72: time.sleep( 1 )
73: # Redémarre l’ESP
74: reset()
75:
76: if runapp.value() != 1:
77: from sys import exit
78: exit(0)
79:
80: led.value( 0 ) # allumer
81:
82: # --- Programme Pincipal ---
83: from umqtt.simple import MQTTClient
84: try:
85: q = MQTTClient( client_id = CLIENT_ID, server =
86: MQTT_SERVER, user = MQTT_USER, password =
87: MQTT_PSWD )
88: if q.connect() != 0:
89: led_error( step=1 )
90: except Exception as e:
91: print( e )
92: # Vérifier MQTT_SERVER, MQTT_USER, MQTT_PSWD
93: led_error( step=2 )
94:
95: # chargement des bibliothèques
96: try:
97: from ads1x15 import *
98: from machine import Pin
99: except Exception as e:
100: print( e )
101: led_error( step=3 )
102:
103: # déclare le bus i2c
104: i2c = I2C( sda=Pin(4), scl=Pin(5) )
105:
106: # gestion du senseur PIR
107: def pir_activated( p ):
108: # print( ’pir activated @ %s’ % time.time() )
109: global last_pir_time, last_pir_msg, fire_pir_alert
110: last_pir_time = time.time()
111: # Faut-il lancer un message MOUV rapidement?
112: # Initialiser le drapeau pour la boucle principale
113: fire_pir_alert = (last_pir_msg == "NONE")
114:
115: # créer les senseurs
116: try:
117: adc = ADS1115( i2c=i2c, address=0x48, gain=0 )
118:
119: pir_sensor = Pin( PIR_PIN, Pin.IN )
120: pir_sensor.irq( trigger=Pin.IRQ_RISING,
121: handler=pir_activated )
122: except Exception as e:
123: print( e )
124: led_error( step=4 )
125:
126: try:
127: # annonce connexion objet
128: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
129: q.publish( "connect/%s" % CLIENT_ID , sMac )
130: except Exception as e:
131: print( e )
132: led_error( step=5 )
133:
134: import uasyncio as asyncio
135:
136: def capture_1h():
137: """ Exécuté pour capturer des données chaque heure """
138: global q
139: global adc
140: # tmp36 - senseur température
141: valeur = adc.read( rate=0, channel1=0 )
142: mvolts = valeur * 0.1875
143: t = (mvolts - 500)/10
144: t = "{0:.2f}".format(t) # transformer en chaine de
145: # caractères
146: q.publish( "maison/rez/salon/temp", t )
147:
148: def heartbeat():
149: """ Led éteinte 200ms toutes les 10 sec """
150: # PS: LED déjà éteinte par run_every!
151: time.sleep( 0.2 )
152:
153: def pir_alert():
154: """ Envoyer un MOUV en urgence sur topic salon/pir
155: si fire_pir_alert """
156: global fire_pir_alert, last_pir_msg, last_pir_msg_time
157: if fire_pir_alert:
158: fire_pir_alert=False # désactiver l’alerte!
159: last_pir_msg = "MOUV"
160: last_pir_msg_time = time.time()
161: q.publish( "/maison/rez/salon/pir", last_pir_msg )
162:
163: def pir_update():
164: """ Mise à jour régulière du topic salon/pir """
165: global last_pir_msg, last_pir_msg_time
166: if (time.time() - last_pir_msg_time) <
167: PIR_RETRIGGER_TIME:
168: # ce n’est pas le moment d envoyer un
169: # message de mise-à-jour
170: return
171:
172: # PIR activé depuis les x dernière minutes
173: if (time.time() - last_pir_time) < PIR_RETRIGGER_TIME:
174: msg = "MOUV"
175: else:
176: msg = "NONE"
177:
178: # ne pas renvoyer les NONE
179: if msg == "NONE" == last_pir_msg:
180: return
181:
182: # Publier le msg
183: last_pir_msg = msg
184: last_pir_msg_time = time.time()
185: q.publish( "/maison/rez/salon/pir", last_pir_msg )
186:
187:
188: async def run_every( fn, min= 1, sec=None):
189: global led
190: wait_sec = sec if sec else min*60
191: while True:
192: led.value( 1 ) # éteindre pendant envoi/traitement
193: try:
194: fn()
195: except Exception:
196: print( "run_every catch exception for %s" % fn)
197: raise # quitter boucle
198: led.value( 0 ) # allumer
199: await asyncio.sleep( wait_sec )
200:
201: async def run_app_exit():
202: """ fin d’exécution lorsque la fonction quitte """
203: global runapp
204: while runapp.value()==1:
205: await asyncio.sleep( 10 )
206: return
207:
208: loop = asyncio.get_event_loop()
209: loop.create_task( run_every(capture_1h, min=60) )
210: loop.create_task( run_every(pir_alert, sec=10) )
211: loop.create_task( run_every(pir_update, min=5))
212: loop.create_task( run_every(heartbeat, sec=10) )
213: try:
214: loop.run_until_complete( run_app_exit() )
215: except Exception as e :
216: print( e )
217: led_error( step=6 )
218:
219: # Désactive l’IRQ
220: pir_sensor = Pin( PIR_PIN, Pin.IN )
221:
222: loop.close()
223: led.value( 1 ) # éteindre
224: print( "Fin!")
Le fonctionnement général de l’objet ayant déjà été décrit, cette section se concentre sur les éléments clés du script.
Les senseurs sont créés entre les lignes 117 et 121.
117: adc = ADS1115( i2c=i2c, address=0x48, gain=0 )
118:
119: pir_sensor = Pin( PIR_PIN, Pin.IN )
120: pir_sensor.irq( trigger=Pin.IRQ_RISING,
121: handler=pir_activated )
La lecture de la température sur le TMP36 passe par le convertisseur ADC, raison de la création de l’objet adc en ligne 117.
Le senseur PIR active un signal de sortie pendant plusieurs secondes lorsqu’il détecte un mouvement. Cette activation est capturée en configurant une interruption sur la broche d’entrée à la ligne 120. L’interruption est déclenchée sur le flan montant du signal avec Pin.IRQ_RISING et traitée par la fonction de rappel pir_activated(). En conséquence, la fonction pir_activated() est rappelée à chaque fois que le senseur PIR est activé.
Le script prévoit trois tâches pour répondre aux spécifications :
209: loop.create_task( run_every(capture_1h, min=60) )
210: loop.create_task( run_every(pir_alert, sec=10) )
211: loop.create_task( run_every(pir_update, min=5))
•.La fonction capture_1h() permet de publier la température toutes les heures.
•.La fonction pir_update() exécutée toutes les 5 minutes prend en charge la publication des messages « MOUV » et « NONE ».
•.La fonction pir_alert() exécutée toutes les 10 secondes prend en charge le cas particulier de l’alerte immédiate lorsque le senseur PIR est activé pour la première fois après une longue période d’inactivité. Dans ce cas particulier, le message « MOUV » doit être publié dès que possible.
La fonction capture_1h() publie la température du TMP36 toutes les heures.
136: def capture_1h():
137: """ Exécuté pour capturer des données chaque heure """
138: global q
139: global adc
140: # tmp36 - senseur température
141: valeur = adc.read( rate=0, channel1=0 )
142: mvolts = valeur * 0.1875
143: t = (mvolts - 500)/10
144: t = "{0:.2f}".format(t) # transformer en chaine de
145: # caractères
146: q.publish( "maison/rez/salon/temp", t )
Ligne 138 et 139 : récupération des variables globales correspondant au ClientMQTT et au convertisseur ADS1115.
Ligne 141 : lecture de l’entrée analogique A0 du convertisseur.
Ligne 142 : conversion en millivolts. Le rapport de conversion 0,1875 dépend du gain de l’amplificateur (voir ligne 117). Le rapport à utiliser est détaillé dans le chapitre ESP8266 sous MicroPython - Programmer.
Ligne 143 : conversion de la tension (mV) en température. Formule issue de la fiche technique du TMP36.
Ligne 144 : conversion sous forme de chaîne de caractères avec 2 décimales.
Ligne 146 : publication du message sur le topic maison/rez/salon/temp.
La gestion du senseur PIR nécessite la mise en place de plusieurs variables définies entre les lignes 42 et 50. Celles-ci sont exploitées par les fonctions :
•.pir_activated() : fonction de rappel appelés lors de chaque activation du senseur PIR.
•.pir_update() : mise à jour toutes les 15 minutes des messages « MOUV » et « NONE » sur le broker MQTT.
•.pir_alert() : annonce d’alerte du message « MOUV », sous condition, lors de l’activation du senseur PIR.
40: # PIR
41: PIR_PIN = 13 # Signal du senseur PIR.
42: PIR_RETRIGGER_TIME = 15 * 60 # 15 min
43: # temps (sec) dernière activation PIR
44: last_pir_time = 0
45: last_pir_msg = "NONE"
46: # temps (sec) dernier envoi MSG
47: last_pir_msg_time = 0
48: # Programme principal doit-il envoyer
49: # une notification "MOUV" rapidement?
50: fire_pir_alert = False
Ligne 42 : PIR_RETRIGGER_TIME est le temps de republication du message « MOUV » lorsque le senseur PIR est régulièrement réactivé durant cette période. Constante exprimée en seconde.
Ligne 44 : variable last_pir_time retient l’heure (écoulement du temps en seconde) à laquelle le senseur PIR a été activé pour la dernière fois. Cette variable est assignée par la fonction de rappel pir_activated() du senseur PIR.
Ligne 45 : last_pir_msg est le dernier message envoyé sur le broker MQTT. Pour rappel, « MOUV » est renvoyé toutes les PIR_RETRIGGER_TIME secondes (15 minutes) alors que le message « NONE » ne doit pas être renvoyé.
Ligne 47 : variable last_pir_msg_time retient l’heure (écoulement du temps en seconde) à laquelle le dernier message « MOUV » ou « NONE » a été envoyé sur le broker. Message contenu dans la variable last_pir_msg.
Ligne 50 : le drapeau fire_pir_alert est utilisé pour signaler l’envoi d’un message « MOUV » en urgence, ce qui est typiquement le cas lorsque le senseur PIR est réactivé après une longue période d’inactivité. Ce drapeau est lu dans la fonction pir_alert() pour déclencher l’envoi du message tandis que la fonction de rappel pir_activated(), attachée au senseur PIR, active ce drapeau lorsque les conditions d’envoi sont rencontrées.
Cette fonction de rappel est appelée par l’interruption associée à la broche 13 (voir lignes 120 et 121). Cette fonction est appelée à chaque fois que le senseur PIR est activé.
107: def pir_activated( p ):
108: # print( ’pir activated @ %s’ % time.time() )
109: global last_pir_time, last_pir_msg, fire_pir_alert
110: last_pir_time = time.time()
111: # Faut-il lancer un message MOUV rapidement?
112: # Initialiser le drapeau pour la boucle principale
113: fire_pir_alert = (last_pir_msg == "NONE")
Ligne 109 : récupération des variables globales utiles.
Ligne 110 : mémoriser l’heure de dernière activation du senseur PIR.
Ligne 113 : initialiser le drapeau fire_pir_alert indiquant qu’il faut envoyer un message « MOUV » rapidement. Ce qui est le cas si le dernier message envoyé est « NONE » (plus d’activité) et qu’une nouvelle activité est détectée (ce qui est effectif puisque pir_activated est en cours d’exécution).
La fonction asynchrone pir_alert() est appelée par la boucle de traitement asyncio toutes les 10 secondes. Cette dernière n’a qu’une seule fonction : envoyer rapidement un message « MOUV » lors d’une nouvelle détection de mouvement. Dix secondes est un temps de latence raisonnable pour envoyer l’alerte.
153: def pir_alert():
154: """ Envoyer un MOUV en urgence sur topic salon/pir
155: si fire_pir_alert """
156: global fire_pir_alert, last_pir_msg, last_pir_msg_time
157: if fire_pir_alert:
158: fire_pir_alert=False # desactiver l’alerte!
159: last_pir_msg = "MOUV"
160: last_pir_msg_time = time.time()
161: q.publish( "/maison/rez/salon/pir", last_pir_msg )
Ligne 156 : récupération des variables globales.
Ligne 157 : test du drapeau fire_pir_alert (drapeau modifié par pir_activated). Si ce dernier est vrai, alors il faut envoyer un message « MOUV » (lignes de 158 à 161).
Ligne 158 : désactiver le drapeau pour éviter l’envoi à répétition (toutes les 10 secondes).
Ligne 159 : mémoriser « MOUV » comme dernier message envoyé sur le broker MQTT.
Ligne 160 : mémoriser l’heure d’envoi du message.
Ligne 161 : publication du message « MOUV » sur le broker.
La fonction asynchrone pir_update() est appelée par la boucle de traitement asyncio toutes les 5 minutes. Sa tâche est de faire une mise à jour du topic /maison/rez/salon/pir à intervalle régulier. Donc de répéter les messages « MOUV » lorsque cela est applicable et un unique message « NONE » lorsqu’il n’y a plus de mouvement détecté.
163: def pir_update():
164: """ Mise à jour régulière du topic salon/pir """
165: global last_pir_msg, last_pir_msg_time
166: if (time.time() - last_pir_msg_time) <
167: PIR_RETRIGGER_TIME:
168: # ce n est pas le moment d envoyer un
169: # message de mise-a-jour
170: return
171:
172: # PIR activé depuis les x dernières minutes
173: if (time.time() - last_pir_time) < PIR_RETRIGGER_TIME:
174: msg = "MOUV"
175: else:
176: msg = "NONE"
177:
178: # ne pas renvoyer les NONE
179: if msg == "NONE" == last_pir_msg:
180: return
181:
182: # Publier le msg
183: last_pir_msg = msg
184: last_pir_msg_time = time.time()
185: q.publish( "/maison/rez/salon/pir", last_pir_msg )
Ligne 165 : récupération des variables globales.
Ligne 166 : terminer le traitement de la fonction si le temps écoulé depuis l’envoi du dernier message est inférieur à 15 minutes (PIR_RETRIGGER_TIME secondes).
Lignes 173 à 176 : si le senseur PIR a été activé ces 15 dernières minutes, alors le message « MOUV » doit être envoyé, sinon le message « NONE » servira à signaler la fin d’activité.
Lignes 179 à 180 : terminer le traitement de la fonction si le nouveau message est « NONE » et celui-ci identique au précédent message (« NONE » ne doit être publié qu’une seule fois).
Lignes 183 et 184 : mémorisation du dernier message envoyé, ainsi que de l’heure d’envoi.
Ligne 185 : publication du message sur le topic /maison/rez/salon/pir.
L’intrication des appels des différentes fonctions pir_activated(), pir_alert(), pir_update() laisse entrevoir des problèmes d’accès concurrents entre les interruptions (irq) et asyncio.
La plateforme MicroPython ne gère pas le traitement multitâche et les fonctions asynchrones travaillent en mode coopératif. De la sorte, l’exécution d’une fonction asynchrone ne vient jamais interrompre inopinément l’exécution d’une autre fonction asynchrone.
La seule portion de code pouvant faire l’objet de conditions concurrentes est pir_activated(). Cette dernière est appelée sur base d’une interruption et peut donc interrompre l’exécution d’une fonction testant/modifiant les mêmes variables que celle modifiée par la fonction d’interruption pir_activated().
La prise en compte des conditions de concurrence nécessite la désactivation et la réactivation des interruptions par les instructions machine.disable_irq() et machine.enable_irq() durant le traitement de la section critique. La désactivation des interruptions empêchera l’appel de pir_activated(). La mise en place de la section critique a cependant été écartée dans ce script pour des raisons de simplicité. Le risque concurrent et les conséquences étant tous deux négligeables dans le cas présent.
Pour plus d’informations :
•.Voir l’exemple /esp8266/divers/diss_irq.py dans le dépôt GitHub du projet.
•.Voir : http://docs.micropython.org/en/v1.9.2/esp8266/reference/isr_rules.html
•.Voir aussi une classe Mutex pour MicroPython https://github.com/peterhinch/micropython-samples/tree/master/mutex (sans équivalent pour ESP8266).
Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :
mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.
Les messages suivants apparaissent après la mise-sous-tension de l’objet.
pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
connect/salon 5ccf7f88bb0e
maison/rez/salon/temp 22.09
maison/rez/salon/pir MOUV
Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.
Le schéma suivant présente le montage réalisé pour la surveillance de la véranda. Celui-ci reprend un senseur de température analogique (TMP36) associé à un breakout de conversion analogique/numérique ADS1115. Le breakout est également utilisé avec une photo résistance pour évaluer les conditions d’éclairage du point lumineux et un potentiomètre fixant le seuil utilisé pour déterminé l’état NOIR/ECLAIRAGE de la photorésistance.
L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est abordé en détail dans le chapitre ESP8266 sous MicroPython.
Objet IoT de la véranda
Le script est disponible dans le répertoire esp8266/veranda/main.py du dépôt GitHub de l’ouvrage.
Le fichier bootstrap.sh permet de télécharger les bibliothèques nécessaires au bon fonctionnement de main.py (à savoir ads1x15.py). Le script shell bootstrap.sh peut être exécuté depuis une ligne de commande sur le Raspberry Pi.
Le fichier main.py doit être modifié pour fixer les paramètres correspondant à la configuration actuelle.
Ci-dessous les paramètres à adapter dans le fichier main.py.
MQTT_SERVER = "192.168.1.210"
# Mettre a None si pas utile
MQTT_USER = ’pusr103’
MQTT_PSWD = ’21052017’
Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :
•.main.py - script principal.
•.boot.py - fichier de démarrage. Établit la connexion Wi-Fi.
•.ads1x15.py - bibliothèque utilisée par le script.
Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :
•.1 relevé de température toutes les heures. Pris en charge par le TMP36 et l’ADS1115.
•.Envoi du message « OUVERT » ou « FERME » pour reporter l’ouverture et la fermeture du contact magnétique. Le message est envoyé à chaque changement d’état.
•.Envoi du message « NOIR » ou « ECLAIRAGE » pour reporter l’état de luminosité sur la photorésistance (également appelée ldr).
01: # coding: utf8
02: """ La Maison Pythonic - Object Veranda v0.1
03:
04: Envoi des données de température et du contact magnétique vers le
05: serveur MQTT
06: """
07:
08: from machine import Pin, I2C, reset
09: import time
10: from ubinascii import hexlify
11: from network import WLAN
12:
13: CLIENT_ID = ’veranda’
14:
15: # Utiliser résolution DNS (serveur en ligne)
16: # MQTT_SERVER = ’test.mosquitto.org’
17: #
18: # Attention: MicroPython sous ESP8266 ne gère pas mDns!
19:
20: MQTT_SERVER = "192.168.1.210"
21:
22: # Mettre à None si pas utile
23: MQTT_USER = ’pusr103’
24: MQTT_PSWD = ’21052017’
25:
26: # redémarrage auto après erreur
27: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec
28:
29: # Contact
30: CONTACT_PIN = 13 # Signal du senseur PIR.
31: last_contact_state = 0 # 0=fermé, 1=ouvert
32:
33: # Etat LDR
34: # Valeur d’hysteresis (pour éviter le
35: # basculement continuel)
36: LDR_HYST = 200
37: last_ldr_state = "NOIR" # Noir ou ECLAIRAGE
38:
39: def ldr_to_state( adc_ldr, adc_pivot ):
40: """ Transforme la valeur adc lue en NOIR et ECLAIRAGE
41: """
42: global last_ldr_state
43: # print( "adc_ldr, adc_pivot = %s, %s" %
44: # (adc_ldr, adc_pivot) )
45: if adc_ldr > (adc_pivot+LDR_HYST):
46: return "ECLAIRAGE"
47: elif adc_ldr < (adc_pivot-LDR_HYST):
48: return "NOIR"
49: else:
50: return last_ldr_state
51:
52: # --- Démarrage conditionnel ---
53: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
54: led = Pin( 0, Pin.OUT )
55: led.value( 1 ) # éteindre
56:
57: def led_error( step ):
58: global led
59: t = time.time()
60: while ( time.time()-t ) < ERROR_REBOOT_TIME:
61: for i in range( 20 ):
62: led.value(not(led.value()))
63: time.sleep(0.100)
64: led.value( 1 ) # éteindre
65: time.sleep( 1 )
66: # clignote nbr fois
67: for i in range( step ):
68: led.value( 0 )
69: time.sleep( 0.5 )
70: led.value( 1 )
71: time.sleep( 0.5 )
72: time.sleep( 1 )
73: # Redémarrer l’ESP
74: reset()
75:
76: if runapp.value() != 1:
77: from sys import exit
78: exit(0)
79:
80: led.value( 0 ) # allumer
81:
82: # --- Programme Pincipal ---
83: from umqtt.simple import MQTTClient
84: try:
85: q = MQTTClient( client_id = CLIENT_ID,
86: server = MQTT_SERVER,
87: user = MQTT_USER,
88: password = MQTT_PSWD )
89: if q.connect() != 0:
90: led_error( step=1 )
91: except Exception as e:
92: print( e )
93: led_error( step=2 ) # check MQTT_SERVER, MQTT_USE-
94: MQTT_PSWD
95:
96: # chargement des bibliothèques
97: try:
98: from ads1x15 import *
99: from machine import Pin
100: except Exception as e:
101: print( e )
102: led_error( step=3 )
103:
104: # déclare le bus i2c
105: i2c = I2C( sda=Pin(4), scl=Pin(5) )
106:
107:
108: # créer les senseurs
109: try:
110: adc = ADS1115( i2c=i2c, address=0x48, gain=0 )
111:
112: contact = Pin( CONTACT_PIN, Pin.IN, Pin.PULL_UP )
113: last_contact_state = contact.value()
114: # lire la valeur de la LDR et
115: # déterminer le dernier etat connu
116: last_ldr_state = ldr_to_state(
117: adc_ldr = adc.read( rate=0, channel1=1),
118: adc_pivot = adc.read( rate=0, channel1=2) )
119: except Exception as e:
120: print( e )
121: led_error( step=4 )
122:
123: try:
124: # annonce connexion objet
125: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
126: q.publish( "connect/%s" % CLIENT_ID , sMac )
127: except Exception as e:
128: print( e )
129: led_error( step=5 )
130:
131: import uasyncio as asyncio
132:
133: def capture_1h():
134: """ Exécuté pour capturer des données chaque heure """
135: global q
136: global adc
137: # tmp36 - senseur température
138: valeur = adc.read( rate=0, channel1=0 )
139: mvolts = valeur * 0.1875
140: t = (mvolts - 500)/10
141: # transformer en chaine de caractères
142: t = "{0:.2f}".format(t)
143: q.publish( "maison/rez/veranda/temp", t )
144:
145: def check_contact():
146: """ Publie un message chaque fois que le contact change
147: d’état """
148: global q
149: global last_contact_state
150: # si rien n’a changé
151: if contact.value()==last_contact_state:
152: return
153: # état différent -> déparasitage logiciel
154: time.sleep( 0.100 )
155: # relire l’état et s’assurer qu’il n’a pas changé
156: valeur = contact.value()
157: if valeur != last_contact_state:
158: q.publish( "maison/rez/veranda/portefen",
159: "OUVERT" if valeur==1 else "FERME" )
160: last_contact_state = valeur
161:
162: def check_ldr():
163: global q
164: global adc
165: global last_ldr_state
166: ldr_state = ldr_to_state(
167: adc_ldr = adc.read( rate=0, channel1=1),
168: adc_pivot = adc.read( rate=0, channel1=2) )
169: if ldr_state != last_ldr_state:
170: q.publish( "maison/rez/veranda/ldr", ldr_state )
171: last_ldr_state = ldr_state
172:
173: def heartbeat():
174: """ Led éteinte 200ms toutes les 10 sec """
175: # PS: LED déjà éteinte par run_every!
176: time.sleep( 0.2 )
177:
178:
179: async def run_every( fn, min= 1, sec=None):
180: """ Exécute la fonction fn toutes les minutes ou
181: secondes"""
182: global led
183: wait_sec = sec if sec else min*60
184: while True:
185: led.value( 1 ) # éteindre pendant envoi/traitement
186: try:
187: fn()
188: except Exception:
189: print( "run_every catch exception for %s" % fn)
190: raise # quitter loop
191: led.value( 0 ) # allumer
192: await asyncio.sleep( wait_sec )
193:
194: async def run_app_exit():
195: """ fin d’exécution lorsque quitte la fonction """
196: global runapp
197: while runapp.value()==1:
198: await asyncio.sleep( 10 )
199: return
200:
201: loop = asyncio.get_event_loop()
202: loop.create_task( run_every(capture_1h, min=60) )
203: loop.create_task( run_every(check_contact, sec=2 ) )
204: loop.create_task( run_every(check_ldr, sec=5) )
205: loop.create_task( run_every(heartbeat, sec=10) )
206: try:
207: loop.run_until_complete( run_app_exit() )
208: except Exception as e :
209: print( e )
210: led_error( step=6 )
211:
212: loop.close()
213: led.value( 1 ) # eteindre
214: print( "Fin!")
Le fonctionnement général de l’objet ayant déjà été décrit, cette section se concentre sur les éléments clés du script.
Création du convertisseur ADC ADS1115 en ligne 110 et initialisation de la broche CONTACT_PIN qui sera utilisée pour lire l’état du contact magnétique.
110: adc = ADS1115( i2c=i2c, address=0x48, gain=0 )
111:
112: contact = Pin( CONTACT_PIN, Pin.IN, Pin.PULL_UP )
Les lectures de la température sur le TMP36, de la luminosité (via la photorésistance) et du potentiomètre passent par le convertisseur ADC, raison de la création de l’objet adc en ligne 110.
Le script prévoit trois tâches pour répondre aux spécifications :
202: loop.create_task( run_every(capture_1h, min=60) )
203: loop.create_task( run_every(check_contact, sec=2 ) )
204: loop.create_task( run_every(check_ldr, sec=5) )
•.La fonction capture_1h() permet de publier la température toutes les heures.
•.La fonction check_contact() exécutée toutes les 2 secondes vérifie l’état du contact magnétique et prend en charge la publication des messages « OUVERT » ou « FERME ».
•.La fonction check_ldr() exécutée toutes les 5 secondes vérifie la valeur fournie par la LDR par rapport à la consigne fixée avec le potentiomètre. La fonction prend en charge l’envoi des messages « NOIR » et « ECLAIRAGE » lors du changement d’état.
La fonction capture_1h() publie la température du TMP36 toutes les heures.
133: def capture_1h():
134: """ Exécuté pour capturer des données chaque heure """
135: global q
136: global adc
137: # tmp36 - senseur température
138: valeur = adc.read( rate=0, channel1=0 )
139: mvolts = valeur * 0.1875
140: t = (mvolts - 500)/10
141: # transformer en chaine de caractère
142: t = "{0:.2f}".format(t)
143: q.publish( "maison/rez/veranda/temp", t ))
Lignes 135 et 136 : récupération des variables globales correspondant au ClientMQTT et au convertisseur ADS1115.
Ligne 138 : lecture de l’entrée analogique A0 du convertisseur sur laquelle est branché le senseur de température analogique TMP36.
Ligne 139 : conversion en millivolts. Le rapport de conversion de 0,1875 dépend du gain de l’amplificateur (voir ligne 110). Le rapport à utiliser est détaillé dans le chapitre ESP8266 sous MicroPython - Programmer.
Ligne 140 : conversion de la tension (mV) en température. Formule issue de la fiche technique du TMP36.
Ligne 142 : conversion sous forme de chaîne de caractères avec deux décimales.
Ligne 143 : publication du message sur le topic maison/rez/veranda/temp.
La fonction check_contact() publie le changement d’état du contact magnétique.
Pour commencer, l’état initial du contact magnétique est lu lors de l’initialisation de la broche CONTACT_PIN. Voir ligne 113.
112: contact = Pin( CONTACT_PIN, Pin.IN, Pin.PULL_UP )
113: last_contact_state = contact.value()
La variable globale last_contact_state enregistre le dernier état connu du contact. La fonction check_contact (exécutée toutes les 2 secondes) vérifie l’état actuel de la broche par rapport au dernier état connu. Si l’état a changé, alors il faut envoyer le message correspondant.
145: def check_contact():
146: """ Publie un message chaque fois que le contact change
147: d’état """
148: global q
149: global last_contact_state
150: # si rien n’a changé
151: if contact.value()==last_contact_state:
152: return
153: # état différent -> déparasitage logiciel
154: time.sleep( 0.100 )
155: # relire l’état et s’assurer qu’il n’a pas changé
156: valeur = contact.value()
157: if valeur != last_contact_state:
158: q.publish( "maison/rez/veranda/portefen",
159: "OUVERT" if valeur==1 else "FERME" )
160: last_contact_state = valeur
Lignes 148 et 149 : récupération des variables globales correspondant au ClientMQTT et au dernier état connu du contact magnétique.
Lignes 151 et 152 : si l’état de la broche n’a pas changé depuis la dernière exécution de check_contact(), alors il n’est pas nécessaire d’envoyer de message au broker. L’exécution de la fonction s’achève par l’appel de l’instruction return.
Ligne 154 : déparasitage logiciel de l’entrée. Le contact magnétique peut avoir été rompu (ou rétabli) n’importe quand durant le délai entre deux appels consécutifs de check_contact(). Le changement d’état peut aussi avoir lieu au moment de l’appel de la fonction, dans ce cas, un certain nombre de rebonds peuvent avoir lieu durant l’éloignement ou l’approche de l’aimant. Un délai de 100 ms est plus approprié au changement d’état d’un contact magnétique alors que 10ms seront réservés au déparasitage d’un bouton poussoir.
Lignes 156 et 157 : relecture de l’état et exécution de la publication si l’état est effectivement changé.
Ligne 158 : publication du message correspondant à l’état du contact magnétique. L’entrée étant au niveau bas lorsque le contact est fermé, l’expression ternaire "OUVERT" if valeur==1 else "FERME" retourne « OUVERT » si la broche est au niveau haut et « FERME » dans le cas contraire.
Ligne 159 : mémoriser l’état courant comme dernier état connu pour éviter le renvoi du message lors de la prochaine exécution de check_contact().
La fonction check_ldr() vérifie le niveau de luminosité de la photorésistance pour déterminer l’état « NOIR » ou « ECLAIRAGE ».
La fonction s’appuie sur la variable globale last_ldr_state pour mémoriser le dernier état connu et la fonction ldr_to_state() permet d’évaluer l’état correspondant à la mesure de la photorésistance.
La ligne 36 définit une constante d’hystérésis, cette dernière évite le basculement continuel entre les deux états NOIR et ECLAIRAGE lorsque la valeur analogique A1 retournée pour la photorésistance oscille autour de la valeur pivot.
36: LDR_HYST = 200
À noter que la valeur pivot est définie à l’aide d’un potentiomètre sur l’entrée analogique A2 (valeur numérique entre 0 et 32767).
Changement d’état et hystérésis
L’état ne bascule de « NOIR » à « ECLAIRAGE » (courbe bleue, de gauche à droite) que lorsque la valeur lue dépasse la valeur pivot de 200 unités, valeur définie dans LDR_HYST. De même, l’état ne bascule de « ECLAIRAGE » à « NOIR » (courbe rouge, de droite à gauche) que lorsque la valeur lue est inférieure de 200 unités à la valeur pivot.
Voici donc le détail de la fonction ldr_to_state( adc_ldr, adc_pivot ) qui détermine l’état « NOIR » ou « ECLAIRAGE » par rapport à la valeur de pivot.
39: def ldr_to_state( adc_ldr, adc_pivot ):
40: """ Transforme la valeur adc lue en NOIR et ECLAIRAGE
41: """
42: global last_ldr_state
43: # print( "adc_ldr, adc_pivot = %s, %s" %
44: # (adc_ldr, adc_pivot) )
45: if adc_ldr > (adc_pivot+LDR_HYST):
46: return "ECLAIRAGE"
47: elif adc_ldr < (adc_pivot-LDR_HYST):
48: return "NOIR"
49: else:
50: return last_ldr_state
Ligne 39 : le paramètre adc_ldr contient la valeur lue pour la photorésistance. adc_pivot est la valeur de pivot fixée à l’aide du potentiomètre.
Ligne 42 : récupération de la variable globale contenant le dernier état connu de la photorésistance (cette valeur est également le message envoyé sur le broker).
Ligne 45 : si la valeur de la photorésistance est supérieure au pivot + hystérésis, alors l’état est inévitablement « ECLAIRAGE », valeur retournée à la ligne 46.
Ligne 47 : sinon, le code vérifie si la valeur de la photorésistance est inférieure à la valeur pivot - hystérésis auquel cas l’état est inévitablement « NOIR », valeur retournée à la ligne 48.
Lignes 49 et 50 : si les tests en lignes 45 et 47 échouent, alors la valeur de la photorésistance se situe dans la zone d’hystérésis autour de la valeur pivot. Dans ce cas, il n’y a pas de changement d’état et c’est le dernier état connu last_ldr_state qui est retourné.
Une fois les lignes 43 et 44 décommentées, elles affichent la valeur de la photorésistance et du pivot dans une session REPL. Cela permet de faciliter le réglage du pivot.
Il est maintenant possible de se pencher sur la fonction check_ldr() prenant en charge la vérification du changement d’état, ainsi que la publication de celui-ci sur le broker MQTT. La fonction check_ldr() est appelée toutes les 5 secondes par la boucle de traitement.
162: def check_ldr():
163: global q
164: global adc
165: global last_ldr_state
166: ldr_state = ldr_to_state(
167: adc_ldr = adc.read( rate=0, channel1=1),
168: adc_pivot = adc.read( rate=0, channel1=2) )
169: if ldr_state != last_ldr_state:
170: q.publish( "maison/rez/veranda/ldr", ldr_state )
171: last_ldr_state = ldr_state
Lignes 163 à 165 : récupération des variables correspondant au client MQTT, convertisseur ADS1115 et au dernier état connu de la photorésistance.
Ligne 166 : utilisation de la fonction ldr_to_state() pour évaluer l’état de la photorésistance résultant de la lecture de l’entrée analogique A1 correspondant à celle-ci (voir adc_ldr=) et de l’entrée analogique A2 correspondant à la valeur pivot fixée à l’aide du potentiomètre (voir adc_pivot=).
Ligne 169 : si l’état de la photorésistance a changé alors exécution de la publication du nouvel état ldr_state (ligne 170) et mémorisation du nouvel état comme dernier état connu (ligne 171).
Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :
mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.
Les messages suivants apparaissent après la mise-sous-tension de l’objet. Ouvrir et fermer le contact magnétique, et couvrir la photo résistance pour voir d’autres messages complémentaires.
pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
connect/veranda 5ccf7fefafeb
maison/rez/veranda/temp 18.63
maison/rez/veranda/portefen FERME
maison/rez/veranda/portefen OUVERT
maison/rez/veranda/portefen FERME
maison/rez/veranda/ldr NOIR
maison/rez/veranda/ldr ECLAIRAGE
maison/rez/veranda/portefen OUVERT
Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.
Le schéma suivant présente le montage réalisé pour la commande de la chaufferie. La commande est prise en charge par un relais qui permet d’activer le circuit de commande de la chaufferie.
Un senseur de température numérique DS18B20 permet de relever la température du circuit d’eau à intervalles réguliers.
L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est abordé en détail dans le chapitre ESP8266 sous MicroPython .
Le fonctionnement du relais peut être simulé par une LED comme indiqué sur le schéma suivant.
Utiliser une LED pour simuler le fonctionnement du relais
Le script est disponible dans le répertoire esp8266/chaufferie/main.py du dépôt GitHub de l’ouvrage.
Le fichier main.py doit être modifié pour fixer les paramètres correspondant à la configuration réseau actuelle.
Ci-dessous les paramètres à adapter dans le fichier main.py.
MQTT_SERVER = "192.168.1.210"
# Mettre a None si pas utile
MQTT_USER = ’pusr103’
MQTT_PSWD = ’21052017’
Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :
•.main.py - script principal.
•.boot.py - fichier de démarrage. Établit la connexion Wi-Fi.
Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :
Le topic maison/cave/chaufferie/cmd permet d’envoyer des commandes. Les commandes supportées sont :
•.MARCHE : mise en marche de la chaufferie, fermeture du relais.
•.ARRET : mise à l’arrêt de la chaufferie, ouverture du relais.
À noter que l’objet doit rejeter deux demandes de changement d’état dans un même intervalle de 10 secondes. Les changements d’état de la chaudière sont communiqués par l’objet sur le topic maison/cave/chaufferie/etat.
Une sonde de température numérique DS18B20 permet de relever la température de l’eau du circuit de radiateurs toutes les heures. Température communiquée sur le topic maison/cave/chaufferie/temp-eau.
Une exception cependant, lors d’un changement d’état de la chaudière, la cadence de communication des relevés de température est réduite à 10 minutes pendant une heure.
01: # coding: utf8
02: """ La Maison Pythonic - Object chaufferie v0.1
03:
04: Envoi des données température et activation de la
05: chaufferie via serveur MQTT
06: """
07:
08: from machine import Pin, reset
09: import time
10: from ubinascii import hexlify
11: from network import WLAN
12:
13:
14: CLIENT_ID = ’chaufferie’
15:
16: # Utiliser résolution DNS (serveur en ligne)
17: # MQTT_SERVER = ’test.mosquitto.org’
18: #
19: # Utiliser IP si le Pi est en adresse fixe
20: # (plus fiable sur réseau local/domestique)
21: # MQTT_SERVER = ’192.168.1.220’
22: #
23: # Utiliser le hostname si Pi est en DHCP et que la propagation
24: # du # hostname atteind le modem/router (voir aussi
25: # gestion mDns sur router). # (pas forcément fiable sur
26: # réseau domestique)
27: # MQTT_SERVER = ’pythonic’
28: #
29: # Attention: MicroPython sous ESP8266 ne gère pas mDns!
30:
31: MQTT_SERVER = "192.168.1.210"
32:
33: # Mettre a None si pas utile
34: MQTT_USER = ’pusr103’
35: MQTT_PSWD = ’21052017’
36:
37: # redémarrage auto après erreur
38: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec
39:
40: # chaudière
41: CHAUD_PIN = 13 # Broche activation relais chaudière.
42: chaud = None # objet Pin de la chaudière
43: last_chaud_state = None # Dernier état connu
44: # temps (sec) du dernier chg d’état
45: last_chaud_state_time = 0
46:
47: # Senseur Temp. DS18B20
48: DS18B20_PIN = 2
49: ds = None # class DS18x20
50: ds_rom = None # Adresse du ds18b20 sur le bus OneWire
51:
52: # --- Démarrage conditionnel ---
53: runapp = Pin( 12, Pin.IN, Pin.PULL_UP )
54: led = Pin( 0, Pin.OUT )
55: led.value( 1 ) # éteindre
56:
57: def led_error( step ):
58: global led
59: t = time.time()
60: while ( time.time()-t ) < ERROR_REBOOT_TIME:
61: for i in range( 20 ):
62: led.value(not(led.value()))
63: time.sleep(0.100)
64: led.value( 1 ) # éteindre
65: time.sleep( 1 )
66: # clignote nbr fois
67: for i in range( step ):
68: led.value( 0 )
69: time.sleep( 0.5 )
70: led.value( 1 )
71: time.sleep( 0.5 )
72: time.sleep( 1 )
73: # Redémarrage de l’ESP
74: reset()
75:
76: if runapp.value() != 1:
77: from sys import exit
78: exit(0)
79:
80: led.value( 0 ) # allumer
81:
82: # --- Programme Pincipal ---
83: def sub_cb( topic, msg ):
84: """ fonction de rappel pour souscriptions MQTT """
85: # debogage
86: #print( ’-’*20 )
87: #print( topic )
88: #print( msg )
89:
90: # bytes -> str
91: t = topic.decode( ’utf8’ )
92: m = msg.decode(’utf8’)
93: try:
94: if t == "maison/cave/chaufferie/cmd":
95: chaud_exec_cmd( cmd = m )
96: except Exception as e:
97: # Capturer TOUTE exception sur souscription
98: # Ne pas crasher check_mqtt_sub et
99: # asyncio.run_until_complete et l’ESP!
100:
101: # Info debug sur REPL
102: print( "="*20 )
103: print( "Subscriber callback (sub_cb) catch an
104: exception:" )
105: print( e )
106: print( "topic et message" )
107: print( t )
108: print( m )
109: print( "="*20 )
110:
111: from umqtt.simple import MQTTClient
112: try:
113: q = MQTTClient( client_id = CLIENT_ID,
114: server = MQTT_SERVER,
115: user = MQTT_USER, password = MQTT_PSWD )
116: q.set_callback( sub_cb )
117:
118: if q.connect() != 0:
119: led_error( step=1 )
120:
121: q.subscribe( ’maison/cave/chaufferie/cmd’ )
122: except Exception as e:
123: print( e )
124: # check MQTT_SERVER, MQTT_USER, MQTT_PSWD
125: led_error( step=2 )
126:
127: # chargement des bibliothèques
128: try:
129: from onewire import OneWire
130: from ds18x20 import DS18X20
131: from machine import Pin
132: except Exception as e:
133: print( e )
134: led_error( step=3 )
135:
136: # déclare le bus i2c (if any)
137: #
138: # i2c = I2C( sda=Pin(4), scl=Pin(5) )
139:
140: # créer les senseurs
141: try:
142: # Senseur temp DS18B20
143: ds = DS18X20( OneWire(Pin(DS18B20_PIN)))
144: roms = ds.scan()
145: if len(roms) == 0:
146: raise Exception( ’ds18b20 not available!’)
147: ds_rom = roms[0]
148:
149: # Chaudière - init à l’arrêt
150: chaud = Pin( CHAUD_PIN, Pin.OUT )
151: chaud.value( 0 )
152: last_chaud_state = "ARRET"
153: except Exception as e:
154: print( e )
155: led_error( step=4 )
156:
157: try:
158: # annonce connexion objet
159: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()
160: q.publish( "connect/%s" % CLIENT_ID , sMac )
161: # Annonce l’état
162: except Exception as e:
163: print( e )
164: led_error( step=5 )
165:
166: import uasyncio as asyncio
167:
168: def chaud_exec_cmd( cmd ):
169: """ Exécuter la commande cmd sur la chaudière.
170: Modifie l état de la chaudière et faire
171: les notifications """
172: assert cmd in ("MARCHE","ARRET"), "Invalid chaud cmd"
173:
174: global q
175: global last_chaud_state
176: global last_chaud_state_time
177: global chaud
178:
179: # Si pas chg d état -> rien faire
180: if cmd == last_chaud_state:
181: return
182: # éviter plusieurs changements d’état en 10 sec
183: if (time.time()-last_chaud_state_time)<10:
184: q.publish( "maison/cave/chaufferie/etat",
185: "REJECT-CMD" ) # informer du refus
186: time.sleep(0.100)
187: q.publish( "maison/cave/chaufferie/etat",
188: last_chaud_state ) # Renvoyer l etat
189: return
190: # changement d’état
191: last_chaud_state = cmd
192: last_chaud_state_time = time.time() # en sec
193: # changer état relais
194: chaud.value( 1 if cmd == "MARCHE" else 0 )
195: # Notification MQTT du nouvel état
196: # Etat = commande = ("ARRET","ARRET")
197: q.publish( "maison/cave/chaufferie/etat", cmd )
198: # Force la publication de la température maintenant!
199: capture_1h()
200:
201: def capture_1h():
202: """ Exécutée pour capturer la température chaque heure
203: """
204: global ds
205: global ds_rom
206: # ds18b20 - senseur température
207: ds.convert_temp()
208: time.sleep_ms( 750 )
209: valeur = ds.read_temp( ds_rom )
210: # transformer en chaine de caractères
211: t = "{0:.2f}".format(valeur)
212: q.publish( "maison/cave/chaufferie/temp-eau", t )
213:
214: def capture_10m():
215: """ Capture de la température toutes les 10min (mais
216: dans l heure suivant un changement d état de
217: la chaudière) """
218: global last_chaud_state_time
219: # Dans les 3600 sec (1h) après
220: if last_chaud_state_time and
221: ( (time.time() - last_chaud_state_time) < 3600 ):
222: # exécution par la routine de capture
223: capture_1h()
224:
225: def heartbeat():
226: """ Led éteinte 200ms toutes les 10 sec """
227: # PS: LED déjà éteinte par run_every!
228: time.sleep( 0.2 )
229:
230: def check_mqtt_sub():
231: """ Traitement des messages venant de MQTT """
232: global q
233: # Appel wait_msg() non bloquant.
234: # Provoque l’appel de sub_cb()
235: q.check_msg() # prendre un message (si présent)
236:
237: async def run_every( fn, min= 1, sec=None):
238: """ Exécute la fonction fn toutes les minutes
239: ou secondes"""
240: global led
241: wait_sec = sec if sec else min*60
242: while True:
243: led.value( 1 ) # éteindre pendant envoi/traitement
244: try:
245: fn()
246: except Exception:
247: print( "run_every catch exception for %s" % fn)
248: raise # quitter loop
249: led.value( 0 ) # allumer
250: await asyncio.sleep( wait_sec )
251:
252: async def run_app_exit():
253: """ fin d’exécution lorsque la fonction quitte """
254: global runapp
255: while runapp.value()==1:
256: await asyncio.sleep( 10 )
257: return
258:
259: loop = asyncio.get_event_loop()
260: loop.create_task( run_every(capture_1h , min=60) )
261: loop.create_task( run_every(capture_10m , min=10) )
262: loop.create_task( run_every(heartbeat , sec=10 ) )
263: loop.create_task( run_every(check_mqtt_sub, sec=2.5) )
264: try:
265: # Annonce l’état initial
266: q.publish( "maison/cave/chaufferie/etat",
267: last_chaud_state )
268:
269: # Exécution du scheduler
270: loop.run_until_complete( run_app_exit() )
271: except Exception as e :
272: print( e )
273: led_error( step=6 )
274:
275: loop.close()
276: led.value( 1 ) # eteindre
277: print( "Fin!")
Le fonctionnement général de l’objet ayant déjà été décrit, cette section se concentre sur les éléments clés du script.
Déclaration de la fonction sub_cb( topic, msg ) en ligne 83 permettant de traiter les messages MQTT entrants, et les messages envoyés par le broker suite aux différentes souscriptions de l’objet. Les détails de cette fonction seront abordés ultérieurement.
La ligne 113 crée une instance du Client MQTT tandis que la ligne 116 assigne la fonction de rappel sub_cb pour tous les messages entrants.
83: def sub_cb( topic, msg ):
84: """ fonction de rappel pour souscriptions MQTT """
...
111: from umqtt.simple import MQTTClient
112: try:
113: q = MQTTClient( client_id = CLIENT_ID,
114: server = MQTT_SERVER,
115: user = MQTT_USER, password = MQTT_PSWD )
116: q.set_callback( sub_cb )
...
Création du réseau de senseurs de température OneWire DS18B20 en ligne 143 et identification de l’unique senseur DS18B20 en ligne 147.
La broche CHAUD_PIN, en ligne 150, est utilisée en sortie pour commander le relais de la chaudière qui y est raccordé. Initialisation de la broche et de la variable d’état last_chaud_state à l’arrêt.
142: # Senseur temp DS18B20
143: ds = DS18X20( OneWire(Pin(DS18B20_PIN)))
144: roms = ds.scan()
145: if len(roms) == 0:
146: raise Exception( ’ds18b20 not available!’)
147: ds_rom = roms[0]
148:
149: # Chaudière - init à l’arrêt
150: chaud = Pin( CHAUD_PIN, Pin.OUT )
151: chaud.value( 0 )
152: last_chaud_state = "ARRET"
Le script prévoit trois tâches pour répondre aux spécifications :
260: loop.create_task( run_every(capture_1h , min=60) )
261: loop.create_task( run_every(capture_10m , min=10) )
262: loop.create_task( run_every(heartbeat , sec=10 ) )
263: loop.create_task( run_every(check_mqtt_sub, sec=2.5) )
•.La fonction capture_1h() permet de publier la température toutes les heures.
•.La fonction capture_10m() exécutée toutes les 10 minutes permet de publier la température toutes les 10 minutes durant l’heure suivant le changement d’état de la chaudière.
•.La fonction check_mqtt_sub() exécutée toutes les 2,5 secondes vérifie la présence d’un seul message MQTT entrant. Si un message est présent, cela provoquera l’exécution de la fonction de rappel sub_cb() supposée traiter le contenu des messages envoyés par le broker MQTT.
À noter que l’état de la chaudière est communiqué à son lancement, à la ligne 266 juste avant l’exécution de la boucle de traitement asynchrone.
264: try:
265: # Annonce l’état initial
266: q.publish( "maison/cave/chaufferie/etat",
267: last_chaud_state )
268:
269: # Exécution du scheduler
270: loop.run_until_complete( run_app_exit() )
271: except Exception as e :
272: print( e )
273: led_error( step=6 )
La fonction capture_1h() publie la température du DS18B20 toutes les heures.
201: def capture_1h():
202: """ Exécutée pour capturer la température chaque heure
203: """
204: global ds
205: global ds_rom
206: # ds18b20 - senseur température
207: ds.convert_temp()
208: time.sleep_ms( 750 )
209: valeur = ds.read_temp( ds_rom )
210: # transformer en chaine de caractères
211: t = "{0:.2f}".format(valeur)
212: q.publish( "maison/cave/chaufferie/temp-eau", t )
Lignes 204 et 205 : récupération des variables globales correspondant au ClientMQTT et au senseur DS18B20.
Ligne 207 : demande de capture de température sur le bus OneWire.
Ligne 208 : attente du temps réglementaire nécessaire à la réception des données sur le bus OneWire.
Ligne 209 : récupération de la température pour le senseur ds_rom (unique senseur du bus identifié sur le bus (voir ligne 147).
Ligne 211 : conversion sous forme de chaîne de caractères avec deux décimales.
Ligne 212 : publication du message sur le topic maison/cave/chaufferie/temp-eau.
La fonction capture_10m() permet de communiquer un relevé de température toutes les 10 minutes durant l’heure suivant un changement d’état de la chaudière.
214: def capture_10m():
215: """ Capture de la température toutes les 10min (mais
216: dans l heure suivant un changement d état de
217: la chaudière) """
218: global last_chaud_state_time
219: # Dans les 3600 sec (1h) après
220: if last_chaud_state_time and
221: ( (time.time() - last_chaud_state_time) < 3600 ):
222: # exécution par la routine de capture
223: capture_1h()
Ligne 218 : récupération de la variable globale last_chaud_state_time mémorisant l’heure du dernier changement d’état de la chaudière.
Ligne 220 : test en deux parties qui vérifie que la variable last_chaud_state_time est assignée (cette valeur est non assignée au démarrage du script étant donné qu’il n’y a pas encore eu de changement d’état).
Ligne 221 : suite du test vérifiant que l’on se situe dans l’intervalle d’une heure après le dernier changement d’état. La soustraction time.time() - last_chaud_state_time retourne le nombre de secondes écoulées depuis last_chaud_state_time.
Ligne 223 : publication de la température en utilisant la fonction de capture horaire.
La fonction check_mqtt_sub() vérifie la présence d’un message toutes les 2.5 secondes.
230: def check_mqtt_sub():
231: """ Traitement des messages venant de MQTT """
232: global q
233: # Appel wait_msg() non bloquant.
234: # Provoque l’appel de sub_cb()
235: q.check_msg() # prendre un message (si présent)
Ligne 232 : récupération de variable globale du Client MQTT.
Ligne 235 : l’appel de check_msg() vérifie s’il y a un message MQTT à traiter. Cet appel est non bloquant, ce qui signifie que la fonction check_msg() termine immédiatement son exécution s’il n’y a pas de message. Dans le cas contraire, le message sera chargé et la fonction de rappel sub_cb() sera appelée pour traiter le contenu du message.
La fonction sub_cb() est appelée par le client MQTT lorsqu’un message est reçu. Cette fonction est appelée pour toutes les souscriptions du client MQTT et doit donc prendre en charge les différents cas de figure.
83: def sub_cb( topic, msg ):
84: """ fonction de rappel pour souscriptions MQTT """
85: # débogage
86: #print( ’-’*20 )
87: #print( topic )
88: #print( msg )
89:
90: # bytes -> str
91: t = topic.decode( ’utf8’ )
92: m = msg.decode(’utf8’)
93: try:
94: if t == "maison/cave/chaufferie/cmd":
95: chaud_exec_cmd( cmd = m )
96: except Exception as e:
97: # Capturer TOUTE exception sur souscription
98: # Ne pas crasher check_mqtt_sub et
99: # asyncio.run_until_complete et l’ESP!
100:
101: # Info debug sur REPL
102: print( "="*20 )
103: print( "Subscriber callback (sub_cb) catch an
104: exception:" )
105: print( e )
106: print( "for topic and message" )
107: print( t )
108: print( m )
109: print( "="*20 )
Lignes 85-86 : lignes à décommenter pour afficher le contenu du topic et le message reçu par l’objet dans une session REPL. Pratique pour vérifier les messages entrants.
Lignes 91-92 : transformation du topic et message du type bytes (tableau d’octets) en chaîne de caractères.
Ligne 94 : vérifie s’il s’agit d’un message de commande pour la chaudière (donc publié sur le topic « maison/cave/chaufferie/cmd ».
Ligne 95 : le message de commande m est passé en paramètre à la fonction chaud_exec_cmd() qui traite l’exécution de la commande.
Lignes 96-109 : utilisation d’une section except pour capturer et étouffer toutes les exceptions pouvant survenir dans la fonction de rappel. Si une exception atteignait le client MQTT, cela interromprait la boucle de traitement asynchrone et le fonctionnement de l’objet. Étouffer l’erreur permet de maintenir en fonction les autres fonctionnalités de l’objet. Affichage du message d’exception, du topic et message MQTT dans la session REPL.
La fonction chaud_exec_cmd() est appelée par la fonction de rappel sub_cb() lorsqu’un message de commande pour la chaudière est reçu. Cette fonction prend en charge le traitement de la commande.
168: def chaud_exec_cmd( cmd ):
169: """ Exécuter la commande cmd sur la chaudière.
170: Modifie l état de la chaudière et faire
171: les notifications """
172: assert cmd in ("MARCHE","ARRET"), "Invalid chaud cmd"
173:
174: global q
175: global last_chaud_state
176: global last_chaud_state_time
177: global chaud
178:
179: # Si pas chg d état -> rien faire
180: if cmd == last_chaud_state:
181: return
182: # éviter plusieurs chg état en 10 sec
183: if (time.time()-last_chaud_state_time)<10:
184: q.publish( "maison/cave/chaufferie/etat",
185: "REJECT-CMD" ) # informer du refus
186: time.sleep(0.100)
187: q.publish( "maison/cave/chaufferie/etat",
188: last_chaud_state ) # Renvoyer l’état
189: return
190: # chg d’état
191: last_chaud_state = cmd
192: last_chaud_state_time = time.time() # en sec
193: # changer état relais
194: chaud.value( 1 if cmd == "MARCHE" else 0 )
195: # Notification MQTT du nouvel état
196: # Etat = commande = ("ARRET","ARRET")
197: q.publish( "maison/cave/chaufferie/etat", cmd )
198: # Force la publication de la temperature maintenant!
199: capture_1h()
Ligne 172 : vérifie que le message de commande cmd est dans les valeurs admissibles. Dans le cas contraire, l’instruction assert lève une exception AssertionError.
Lignes 174-177 : récupération des variables globales. Respectivement : le client MQTT, le dernier état connu de la chaudière (last_chaud_state), l’heure à laquelle le dernier état a été fixé (last_chaud_state_time) et la broche de commande de la chaudière (chaud).
Lignes 180-181 : terminer l’exécution de la fonction si la commande demande de placer la chaudière dans l’état dans lequel elle est déjà.
Lignes 183-189 : vérifie si le temps écoulé depuis le dernier changement d’état est bien supérieur à 10 secondes. Si ce n’est pas le cas, la fonction publie un message « REJECT-CMD » avant de republier l’état actuel de la chaudière. Enfin l’exécution de la fonction se termine.
Lignes 191-192 : toutes les conditions de rejet ayant été écartées, il faut maintenant exécuter l’application du nouvel état. Pour commencer, le nouvel état cmd est mémorisé comme dernier état connu dans last_chaud_state. Enregistrement de l’heure actuelle comme heure de dernier changement d’état (last_chaud_state_time). À partir de maintenant, la fonction capture_10m() pourra publier la température toutes les 10 minutes pendant une heure.
Ligne 194 : application du nouvel état sur la broche de sortie. L’expression ternaire 1 if cmd == "MARCHE" else 0 retourne 1 si le message de commande contient « MARCHE ». Cela aura pour effet d’activer la broche et le relais qui y est branché. Dans le cas contraire, l’expression retournera 0 et le relais sera désactivé.
Ligne 197 : publication du nouvel état sur le topic maison/cave/chaufferie/etat.
Ligne 199 : publier immédiatement un premier relevé de température dans la foulée.
Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :
mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.
Les messages suivants apparaissent après la mise sous tension de l’objet.
pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017
connect/chaufferie 5ccf7fefb1d3
maison/cave/chaufferie/etat ARRET
maison/cave/chaufferie/temp-eau 21.56
Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.
Il est possible de modifier l’état de la chaudière depuis une seconde session de terminal en envoyant des messages sur le topic maison/cave/chaufferie/cmd avec l’utilitaire mosquitto_pub.
mosquitto_pub -h pythonic.local -t "maison/cave/chaufferie/cmd" -m "MARCHE"
-u pusr103 -P 21052017
Ce qui produit les messages complémentaires suivants sur le terminal affichant les messages avec l’utilitaire mosquitto_sub.
maison/cave/chaufferie/cmd MARCHE
maison/cave/chaufferie/etat MARCHE
maison/cave/chaufferie/temp-eau 21.63
maison/cave/chaufferie/temp-eau 21.63
maison/cave/chaufferie/temp-eau 28.19
maison/cave/chaufferie/temp-eau 28.44
maison/cave/chaufferie/temp-eau 31.00
maison/cave/chaufferie/temp-eau 32.25
L’envoi d’une commande incorrecte comme « TEST » avec l’utilitaire mosquitto_pub :
mosquitto_pub -h pythonic.local -t "maison/cave/chaufferie/cmd" -m "TEST"
-u pusr103 -P 21052017
Ne produit aucun message complémentaire suivant sur le terminal affichant les messages avec l’utilitaire mosquitto_sub. La commande est ignorée.
Si une session REPL est ouverte, alors les messages suivants y seront visibles.
====================
Subscriber callback (sub_cb) catch an exception:
Invalid chaud cmd
for topic and message
maison/cave/chaufferie/cmd
TEST
====================
Si le test préliminaire d’un objet ne produit aucune publication sur le broker MQTT, alors les quelques points suivants peuvent vous aider à cerner et corriger le problème.
Problème | Cause | Solution |
La LED reste éteinte |
| Vérifier la position de l’interrupteur RunApp. Vérifier ensuite le fichier boot.py et la connectivité au réseau Wi-Fi. cf. ESP8266 sous MicroPython - Séquence de démarrage MicroPython. Établir une session REPL via la connexion série et presser le bouton Reset. Les erreurs de compilations du script seront affichées dans la session REPL. |
La LED clignote rapidement | Code d’erreur produit par le script en cours d’exécution | Voir la grille « Led de statuts » en début de chapitre (cf. Informations pratiques). Cette liste établit une correspondance entre le code d’erreur, la section du script la produisant et les solutions. Établir une session REPL via la connexion série et presser le bouton Reset. Les erreurs et les messages seront affichés dans la session REPL. |
La LED rapporte systématiquement un code d’erreur 4 ou 6. | Code d’erreur produit par le chargement des bibliothèques des senseurs et la communication avec ces senseurs. | Si le montage contient des senseurs I2C ou SPI alors vérifier :
Désactiver l’objet avec l’interrupteur RunApp, redémarrer l’objet en pressant le bouton Reset et conduire un test du senseur à partir d’une session REPL. Si le senseur répond correctement au test alors l’erreur provient du script main.py. Une exécution surveillée par une session REPL fournira un message d’erreur plus précis. |
Plus de HeartBeat (LED s’éteint 200 ms toutes les 10 secondes). | Le script a été arrêté avec l’interrupteur RunApp. | Placer l’interrupteur RunApp en position de marche et presser le bouton Reset. L’objet se reconnectera sur le réseau Wi-Fi puis le broker MQTT. |
À ce stade du document, les différents objets envoient des données vers le broker MQTT. Il est possible de suivre les différentes publications en utilisant l’utilitaire mosquitto_sub avec la commande suivante :
mosquitto_sub -h pythonic.local -t "#" -u pusr103 -P 21052017 -v
Pour rappel, la connexion sur le broker requiert l’utilisation d’un login (pusr103) et du mot de passe correspondant. Les détails de la commande mosquittto_sub sont abordés dans le chapitre relatif au broker MQTT (cf. Le broker MQTT - Configurer le login du broker MQTT).
Ce chapitre se penche sur le stockage permanent des informations en base de données, ce que n’offre pas une solution comme Mosquitto. Le broker supporte des fonctionnalités MQTT avancées comme la rétention de messages et les clients persistants (cf. Le broker MQTT - La rétention de messages et Les clients persistants), mais ces options avancées ne résistent pas au redémarrage du système.
En effet, une fois le broker redémarré, il faudra attendre que tous les objets aient envoyé des données pour avoir un état général du système dans son ensemble.
Le stockage en base de données permet d’obtenir rapidement un état général du système. Ces informations peuvent certes être obsolètes, mais cela est parfois préférable au manque d’information.
Le stockage en base de données permet :
•.d’avoir une vue générale de l’état du système en interrogeant une ou plusieurs tables,
•.d’enregistrer un historique des valeurs en fonction du temps (souvent appelé time series), ce qui permet de réaliser des graphiques,
•.à d’autres applications d’exploiter tout ou en partie des informations collectées, et l’envoi de messages d’alerte si plusieurs conditions complexes sont rencontrées,
•.de découpler la collecte des informations télémétriques via le broker MQTT et le traitement de ces informations par des applications ou des environnements spécifiques. Par exemple : application web, outil statistique, modélisation mathématique, prévision météo.
Utilité d’une base de données couplée à un broker MQTT
Le fait d’utiliser un Raspberry Pi restreint le choix des moteurs de base de données aux options open sources. Le moteur sélectionné doit être léger, car il doit cohabiter avec un broker MQTT et une application web.
Bien que MySQL/MariaDB soient des options de choix pour un Raspberry Pi, un moteur plus léger comme SQLite sera plus intéressant dans le cas présent.
Alors que MySQL est plutôt basé sur un modèle client-serveur, SQLite intègre le moteur de base de données directement dans le programme hôte sans pour autant exclure les accès concurrents, qui sont gérés au niveau du système de fichier.
SQLite est supporté par Python 2.7 (et Python 3) ainsi que par le projet Flask (serveur web et moteur de rendu en Python qui sera utilisé dans ce projet).
Compte tenu des ressources limitées sur un Raspberry Pi, SQLite est à la fois un choix pertinent et idéal pour épauler le broker MQTT.
Ce chapitre se penche sur le développement du script Python push-to-db.py.
Principe de fonctionnement de push-to-db
Le script push-to-db.py :
•.utilise un fichier de configuration (push-to-db.ini) pour définir les différentes captures - et donc souscriptions - à effectuer sur le broker MQTT,
•.effectue les souscriptions nécessaires sur le broker MQTT. Il se comporte donc comme un client MQTT,
•.maintient une copie du dernier message reçu dans la table topicmsg,
•.maintient des tables timeseries (ts_xxx) qui contiennent des historiques des messages ou des valeurs reçus pour différentes souscriptions. Les critères de capture sont également définis dans le fichier de configuration.
Une petite introduction à SQLite s’impose avant d’entrer dans les détails de fonctionnement du script.
Logo du projet SQLite
SQLite, produit par la société Hwaci (https://www.hwaci.com/), est un moteur de base de données relationnelle open source rapide, léger et fiable. Pesant à peine 300 Kio, SQLite est intégré directement dans le programme hôte. Il ne nécessite donc pas de serveur de base de données.
SQLite utilise un fichier unique pour stocker la totalité de la base de données et s’appuie sur le système de fichiers pour gérer les accès concurrents. Il n’est donc pas conseillé de stocker la base de données sur un lecteur réseau.
SQLite est une base de données répondant au standard ACID assurant une parfaite gestion des transactions et du traitement atomique de celles-ci. SQLite implémente aussi une grande partie du standard SQL-92 permettant de manipuler la base de données avec des requêtes SQL.
Au contraire des moteurs de base de données traditionnels, SQLite n’utilise pas un typage des données statique et rigide (integer, numeric, varchar, etc.), mais un système de type dynamique. SQLite utilise des « classes de stockage » et un système de typage dynamique permettant d’assurer la compatibilité avec la plupart des moteurs et requêtes SQL des bases de données traditionnelles.
Le typage dynamique de SQLite permet de réaliser des opérations qui ne sont pas possibles avec un moteur au typage rigide, il offre également une plus grande souplesse pour le stockage des informations dans la base de données.
Ces différents éléments ont fait de SQLite un outil de choix pour embarquer une base de données au sein d’un logiciel. SQLite est adopté par de nombreux logiciels connus comme Firefox, Skype, Adobe, McAfee ainsi que sur différentes plateformes (Linux, Windows, Mac) et systèmes embarqués (iPhone, Android).
À noter cependant que si SQLite supporte les accès concurrents, cela n’a rien à voir avec la robustesse d’un moteur relationnel de type client-serveur comme MySQL/MariaDB, PostgreSql et autres moteurs commerciaux.
Étant donné que SQLite est destiné à l’intégration d’une base de données au sein même d’un logiciel, ce dernier n’est pas alourdi par la mise en œuvre d’une gestion de rôle et de droit d’accès. Le seul droit d’accès pouvant être contrôlé est celui relatif au système de fichiers sur lequel est stocké le fichier de base de données.
Au contraire des bases de données traditionnelles, SQLite n’utilise pas un typage statique.
Dans une base de données standard, le type de donnée est défini sur chaque colonne et ce typage rigide est appliqué pour toutes les données stockées dans la colonne. Par exemple, il n’est pas question de stocker une chaîne de caractères dans une colonne numérique. Dans le même esprit, il n’est pas possible de stocker plus de 50 caractères si la colonne a été définie pour recevoir au maximum 50 caractères (ex. : varchar(50)).
A contrario, SQLite utilise un système dynamique où le typage de données est stocké avec la donnée elle-même. Ainsi, SQLite est capable de stocker une donnée texte dans une colonne destinée à recevoir des nombres réels. De même, SQLite ne prévoit pas de typage statique (type, format et longueur fixe). Par conséquent, il est possible de stocker une chaîne de caractères, quelle que soit sa longueur. SQLite adapte l’espace de stockage en fonction de la donnée à stocker. Cette approche permet de réaliser des opérations totalement impossibles sur une base de données traditionnelle.
SQLite utilise des classes de stockage et un traitement des affinités de type pour atteindre cette souplesse tout en assurant une compatibilité SQL avec les moteurs traditionnels.
Une classe de stockage permet de définir le typage de données de façon générale. À titre d’exemple, la classe de stockage INTEGER permet de stocker tous les types d’entiers (il existe six différents types d’entiers). Dans une base de données traditionnelle, un TINYINT stocke une valeur entre 0 et 255 (strictement dans cette gamme de valeur). SQLite sera plus souple et acceptera des entiers supérieurs à 255, quitte à adapter la classe de stockage.
Si le stockage de l’information dans le fichier de base de données de SQLite est optimisé (longueur adaptée pour un stockage optimal), cette information reste identifiée comme une classe de stockage INTEGER (entier) une fois l’information rechargée en mémoire.
Classe de données | Description |
INTEGER | Entier signé. Peut être stocké sur 1 à 8 octets en fonction de la valeur. Les valeurs booléennes sont stockées comme des entiers : 0 = False, 1 = True. |
REAL | Valeur en virgule flottante. Stockée en respectant le format IEEE 8 octets. |
TEXT | Chaîne de caractères, stockée en utilisant l’encodage de la base de données : UTF-8, UTF-16BE ou UTF16LE. |
BLOB | Stockage de données binaires en l’état. |
NULL | Sert à mentionner une valeur non assignée, dite « NULL ». |
En SQLite, il est possible de stocker n’importe quelle classe de stockage dans n’importe quelle colonne. Cette souplesse n’est cependant pas applicable pour une colonne INTEGER PRIMARY KEY utilisée pour identifier les enregistrements de façon univoque (colonne où une chaîne de caractères ne sera pas la bienvenue).
Le moteur de SQLite ne dispose pas de classe stockage pour les informations de type date et heure. À la place, SQLite propose des fonctions de conversions permettant de transformer les types date et heure vers les classes de stockages TEXT, REAL et INTEGER.
Conversion vers | Format de stockage utilisé |
TEXT | Utilise le format ISO8601 « AAAA-MM-JJ HH:MM:SS.SSS » (année, mois, jour, heures, minutes, secondes, millisecondes) |
REAL | Nombre de jours dans le calendrier julien (Rome antique). Le nombre de jours depuis le 24 nov. 4714 avant Jésus-Christ, minuit à Greenwich. La partie entière représente un nombre de jours, la partie décimale le temps écoulé. |
INTEGER | Temps écoulé, en secondes, depuis le 01/01/1970 00:00:00 UTC. Correspond à l’encodage du temps Unix. Le projet stocke l’information d’horodatage sous ce format. |
Pour rappel, en SQLite n’importe quel type de donnée peut être stocké dans n’importe quelle colonne, peu importe la classe de stockage utilisée pour maintenir l’information en mémoire.
Ce fonctionnement est en contraste avec les moteurs de base de données relationnels traditionnels qui forcent eux la conversion vers le type de donnée mentionné pour la colonne.
Dans le cadre d’un moteur traditionnel, si la donnée ne peut pas être convertie vers le type de la colonne alors le moteur génère une erreur. Dans le cas de SQLite, l’information sera stockée dans la classe de stockage correspondant au format de la donnée avec identification de la classe de stockage.
L’« affinité de type » est un concept SQLite qui permet de définir un type préférentiel de donnée sur une colonne. Cela indique au moteur SQLite le type recommandé à utiliser pour stocker l’information. Si SQLite ne peut pas s’y conformer, alors il choisira la classe de stockage la plus appropriée.
Cela permet à SQLite de maximiser la compatibilité avec les moteurs de base de données traditionnel.
L’affinité des colonnes est déduite lors de la création de la table. En fonction du type utilisé dans la requête create table, SQLite optera pour une affinité plutôt qu’une autre.
Le tableau ci-dessous reprend les différentes affinités :
•.TEXT
•.NUMERIC
•.INTEGER
•.REAL
•.BLOB
L’affinité TEXT
Les colonnes avec affinité TEXT permettent de stocker les données avec une classe de stockage NULL, TEXT ou BLOB. Les données numériques sont converties en texte avant le stockage.
L’affinité NUMERIC
Les colonnes avec affinité NUMERIC sont destinées à recevoir des valeurs numériques (avec point décimal) sans perte de précision ! Une colonne avec affinité NUMERIC peut contenir des valeurs en utilisant cinq classes de stockage.
Lorsqu’une classe de stockage TEXT est convertie pour être stockée dans une colonne à affinité NUMERIC, SQLite convertit le texte en INTEGER (ou REAL en seconde chance) si cette conversion est réversible et sans perte. Une conversion TEXT vers REAL est considérée réversible s’il est possible de préserver les 15 premières décimales. Si la conversion TEXT vers INTEGER ou REAL n’est pas possible, alors l’information sera stockée en utilisant la classe de stockage TEXT.
À noter que si une classe de stockage contient un texte contenant une valeur convertible sans perte vers une colonne avec affinité INTEGER (ex. : 125.0) alors la conversion vers INTEGER sera privilégiée.
L’affinité INTEGER
L’affinité INTEGER fonctionne comme l’affinité NUMERIC et provient surtout d’opérations de casting.
L’affinité REAL
L’affinité REAL, tout comme INTEGER, fonctionne comme l’affinité NUMERIC. À noter que cette affinité force la représentation des entiers ou celle équivalente en virgule flottante.
L’affinité BLOB
Une colonne avec cette affinité ne présente aucune préférence relative à la classe de stockage. Le moteur SQLite n’essaye pas de forcer l’utilisation d’une classe de stockage par rapport à une autre.
L’affinité de type d’une colonne est déterminée au moment de la création de la table à l’aide de la requête SQL create table.
Voici les règles suivies par le moteur SQLite :
1. | Un type déclaré contenant INT produira une affinité INTEGER. |
2. | Un type déclaré contenant CHAR, CLOB ou TEXT produira une affinité TEXT. |
3. | Un type déclaré contenant BLOB (ou déclaré sans type) produira une affinité BLOB. |
4. | Un type déclaré contenant REAL, FLOAT ou DOUB produira une affinité REAL. |
5. | Sinon, par défaut, l’affinité est « NUMERIC ». |
Exemples :
•.Un type SQL VARCHAR produira une affinité TEXT (règle 2).
•.Un type SQL CHARINT produira une affinité INTEGER (règle 1).
Le résultat final d’un tri de valeurs ou d’expressions dépend aussi de l’affinité ! Une affinité NUMERIC produit un ordre 1, 3, 10, 20, 25, alors qu’une affinité TEXT produit un ordre 1, 10, 20, 25, 3 pour les mêmes éléments.
Il est donc important de connaître ces notions pour aiguiller les recherches en cas de problème.
Une expression est une valeur littéralement incluse dans une requête SQL ou le résultat d’une sous-requête. Par conséquent, l’affinité de type de l’expression est un élément important puisque cette dernière à une implication sur le système de typage dynamique de SQLite et par conséquent le stockage dans la table.
L’affinité de l’expression est déduite comme suit :
1. | Si l’opérande à droite d’un opérateur IN (ou NOT IN) est une liste, alors il n’y a pas d’affinité. |
2. | Si l’opérande à droite d’un opérateur IN (ou NOT IN) est le résultat d’un SELECT, alors l’affinité est identique au résultat du SELECT. |
3. | Lorsque l’expression est une référence vers une colonne d’une table, alors l’affinité est identique à celle de la colonne de la table. |
4. | Lorsque l’expression implique un opérateur sur une colonne d’une table, alors il n’y a plus d’affinité sur le résultat de l’expression. |
À noter que la seule utilisation de parenthèses n’implique pas de modification d’affinité ! L’affinité de ma_colonne et de (ma_colonne) reste identique. Par contre, ma_colonne a une affinité, alors que +ma_colonne n’a plus d’affinité.
5. | Une opération de casting ( cast(expression as type) ) impose l’affinité du résultat. |
6. | Si aucun des points précédents n’est applicable à l’expression, alors cette dernière n’a pas d’affinité. |
La fonction typeof()
SQLite propose une fonction typeof() fort utile pour identifier l’affinité d’une expression ou d’une valeur stockée dans une table.
La session SQLite3 en ligne de commande, voir ci-dessous, permet de vérifier l’affinité des informations stockées dans une table.
$ sqlite3
SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> create table demo(
...> a_text TEXT,
...> a_num NUMERIC,
...> a_int INTEGER,
...> a_real REAL,
...> a_blob BLOB );
sqlite> -- insérer des valeurs comme entier
sqlite> insert into demo
...> values( 123, 123, 123, 123, 123 );
sqlite> select * from demo;
123|123|123|123.0|123
sqlite> select typeof(a_text), typeof(a_num),
...> typeof(a_int), typeof(a_real),
...> typeof(a_blob) from demo;
text|integer|integer|real|integer
sqlite> .quit
Bien que les valeurs stockées soient des entiers (valeur 123), la fonction typeof() démontre que :
•.la valeur entière est stockée comme un texte dans une colonne TEXT (a_text),
•.la valeur entière est stockée comme un entier pour les colonnes NUMERIC et INTEGER (a_num et a_int),
•.la valeur entière est stockée comme réelle dans une colonne REAL (a_real),
•.les colonnes BLOB n’ayant pas d’affinité particulière, un entier est stocké avec l’affinité INTEGER dans ce type de colonne.
Un exercice intéressant est de reproduire ce test en insérant des valeurs avec différentes affinités de type pour voir si celles-ci sont prises en compte par le moteur SQLite.
-- Affinité TEXT
insert into demo values( ’123.5’, ’123.5’, ’123.5’, ’123.5’, ’123.5’ );
-- les colonnes ...
-- a_text|a_num|a_int|a_real|a_blob
-- produisent les typeof() suivants ...
-- text|real|real|real|text
-- Affinité REAL
insert into demo values( 123.5, 123.5, 123.5, 123.5, 123.5 );
-- les colonnes ...
-- a_text|a_num|a_int|a_real|a_blob
-- produisent les typeof() suivants ...
-- text|real|real|real|real
-- Affinité BLOB
-- insertion de données encodées en hexadécimal
insert into demo values( x’A0F3’, x’A0F3’, x’A0F3’, x’A0F3’, x’A0F3’ );
-- les colonnes ...
-- a_text|a_num|a_int|a_real|a_blob
-- produisent les typeof() suivants ...
-- blob|blob|blob|blob|blob
-- Affinité NULL
insert into demo values( NULL, NULL, NULL, NULL, NULL );
-- les colonnes ...
-- a_text|a_num|a_int|a_real|a_blob
-- produisent les typeof() suivants ...
-- null|null|null|null|null
SQLite dispose de l’ensemble des opérateurs de comparaison que l’on retrouve habituellement sur un moteur de base de données (=, ==, <, >, <=, >=,!=, IN, NOT IN, BETWEEN, IS, IS NOT).
SQLite utilisant des classes de stockage et autorisant le typage dynamique des valeurs stockées, il est assez facile d’avoir une situation où les valeurs comparées appartiennent à des classes de stockage différentes.
Cela a un impact sur les tests de comparaison, mais également sur l’ordre de tri (clause ORDER BY) et sur l’agrégation de données (clause GROUP BY)
Voici les règles de comparaison qui s’appliquent :
1. | Une classe de stockage NULL est toujours inférieure à n’importe quelle autre valeur/classe de stockage (y compris une autre classe de stockage NULL). |
2. | Une classe de stockage REAL ou INTEGER est inférieure à une classe de stockage TEXT ou BLOB. |
3. | Une classe de stockage REAL ou INTEGER utilise une comparaison numérique si l’autre opérante est un REAL ou INTEGER. |
4. | Une classe de stockage TEXT est inférieure à une classe de stockage BLOB. |
5. | Une classe de stockage TEXT est comparée à une autre classe TEXT par comparaison des chaînes de caractères en utilisant la séquence de collation appropriée (BINARY, NOCASE, RTRIM). |
6. | Lorsque la comparaison fait intervenir deux classes de stockage BLOB, SQLite utilise une comparaison binaire à l’aide de la fonction memcmp(). |
Conversion de type avant comparaison
Dans certains cas, SQLite effectue une conversion de type avant la comparaison. Cela concerne les classes de stockage INTEGER, REAL et TEXT et s’applique en suivant les règles suivantes :
1. | Si un opérande est INTEGER, REAL ou TEXT et que l’autre opérande est TEXT, BLOB ou sans affinité, alors ce second opérande est converti en affinité NUMERIC avant comparaison. |
2. | Si un opérande est TEXT et que l’autre opérande n’a pas d’affinité, alors ce second opérande est converti en affinité TEXT avant comparaison. |
Dans le cas contraire, aucune conversion d’affinité n’est appliquée avant comparaison.
Opérations de tri
Durant les opérations de tri (clause ORDER BY), les classes de stockage sont ordonnées comme suit :
•.NULL,
•.NUMERIC et REAL (par valeur numérique)
•.TEXT (par comparaison des chaînes de caractères)
•.BLOB (par comparaison binaire)
Opérations de regroupement
Durant les opérations de regroupement (clause GROUP BY), les valeurs ayant des classes de stockage différentes sont considérées comme des valeurs différentes (sauf pour les INTEGER et REAL).
Une clé primaire est une colonne (ou un groupe de colonnes) qui permet d’identifier un enregistrement de façon unique dans la table.
Il y a deux façons de définir une clé primaire :
•.en utilisant la contrainte PRIMARY KEY sur une colonne unique
•.en utilisant une contrainte PRIMARY KEY sur la table lorsque la clé s’étend sur plusieurs colonnes.
L’exemple suivant utilise une contrainte de colonne pour définir une clé primaire sur la colonne id :
create table ts_salon (
id integer primary key,
topic text,
message text,
qos integer,
rectime integer
);
L’exemple ci-dessous utilise une contrainte de table pour définir une contrainte couvrant deux colonnes :
create table demo_pk (
id_1 not null integer,
id_2 not null integer,
infotext text,
primary key(id_1, id_2)
);
Selon le standard SQL en vigueur, une clé primaire ne peut pas contenir de valeur NULL. Cependant, afin de rester compatible avec les précédentes versions de SQLite, celui-ci acceptera une valeur NULL.
À noter que si la colonne clé primaire est précisément de type INTEGER, alors l’insertion d’une valeur NULL provoque l’auto-incrément de la colonne (voir table rowid ci-dessous).
À moins de spécifier WITHOUT ROWID durant la création d’une table, SQLite crée une table dite « table rowid » en ajoutant implicitement une colonne rowid contenant un entier signé sur 64 bits.
Cette colonne rowid est utilisée pour identifier chaque enregistrement dans la table. Le contenu de cette colonne est automatiquement incrémenté à chaque insertion d’enregistrement.
Cette caractéristique est très pratique pour obtenir une table avec une clé auto-incrémentée.
Une « table rowid » stocke les données sous forme d’arbre B, une structure sous forme d’arbre équilibré, car la recherche et le tri d’enregistrements en utilisant un rowid sont très rapides.
Une colonne integer comme clé primaire
Si une table est définie avec une clé primaire sur une seule et unique colonne de type INTEGER (exactement INTEGER), alors SQLite crée un alias vers la colonne rowid.
Cela signifie qu’il n’est pas nécessaire de fixer une valeur pour la clé primaire lors de l’insertion d’un nouvel enregistrement. En effet, SQLite incrémente la valeur la colonne rowid, et par conséquent celle de la colonne de la clé primaire (puisque cette dernière est un alias vers rowid).
L’exemple suivant utilise SQLite3 en ligne de commande et démontre la création d’une ’table rowid’ avec une colonne id en alias implicite sur la colonne rowid. L’insertion d’une valeur NULL dans la colonne id n’empêche pas l’auto-incrémentation de sa valeur.
$ sqlite3
SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> create table ts_demo( id integer primary key,
...> topic text );
sqlite> insert into ts_demo values ( 1, "demo" );
sqlite> insert into ts_demo values ( 1, "demo2" );
Error: UNIQUE constraint failed: ts_salon.id
sqlite> insert into ts_demo values ( 2, "atcg" );
sqlite> insert into ts_demo values ( NULL, "auto-increment" );
sqlite> select * from ts_demo;
1|demo
2|atcg
3|auto-increment
sqlite>
Créer une clé primaire en tant que INTEGER PRIMARY KEY DESC ne crée pas d’alias sur la colonne rowid. La clé primaire doit strictement être INTEGER PRIMARY KEY.
SQLite 3 introduit un nouveau système de verrouillage (lock en anglais) et de journalisation permettant d’améliorer les accès concurrents à la base de données. Ce système de verrouillage réduit également les problèmes de contention lors d’accès en écriture à la base de données.
C’est pour cette raison que le moteur SQLite 3 a été choisi de préférence à SQLite 2.
Du point de vue du processus, le fichier de base de données peut être dans l’un des cinq états de verrouillage suivants :
Verrouillage | Signification |
UNLOCK | Il n’y a aucun verrouillage sur la base de données. La base de données n’est peut-être ni en lecture ni en écriture. Tous les autres processus peuvent lire ou écrire dans la base de données si leur propre état de verrouillage l’autorise. Toutes les données maintenues en cache sont considérées comme invalides et doivent faire l’objet d’une vérification avec les données contenues dans le fichier physique. |
SHARED | La base de données peut être lue, mais pas utilisée en écriture. Plusieurs processus peuvent maintenir un verrouillage SHARED en même temps sur la base de données. Il peut donc y avoir plusieurs processus lisant le contenu de la base de données au même moment. |
RESERVED | Un verrouillage RESERVED indique qu’un processus planifie prochainement une opération d’écriture alors qu’il effectue actuellement des opérations de lecture. Le moteur de base de données n’autorise qu’un seul verrouillage RESERVED à la fois. Ce verrouillage peut cependant coexister avec plusieurs verrouillages de type SHARED. La particularité du verrouillage RESERVED est qu’il autorise le placement de nouveaux verrouillages SHARED pendant la détention du verrouillage RESERVED. |
PENDING | Le verrouillage PENDING indique que le processus détenant le verrouillage désire accéder en écriture sur la base de données dans les plus brefs délais. À la différence du verrouillage RESERVED, le moteur n’accepte plus de nouveaux verrouillages SHARED sur la base de données et attend la clôture des verrouillages SHARED actuellement actifs avant d’offrir l’accès en écriture. Le verrouillage PENDING est l’étape intermédiaire permettant d’accéder au verrouillage EXCLUSIVE. |
EXCLUSIVE | Le verrouillage EXCLUSIVE (exclusif) est requis pour effectuer une opération d’écriture sur la base de données. Un seul verrouillage EXCLUSIVE peut être détenu sur la base de données et plus aucun autre verrouillage ne peut coexister en même temps qu’un verrouillage EXCLUSIVE. SQLite réduit le temps d’opération en verrouillage EXCLUSIVE au strict minimum afin de maximiser les accès concurrents sur la base de données. |
Ce point est important, car c’est également le système d’exploitation qui offre l’accès et les fonctions de verrouillage sur le système de fichiers.
SQLite utilise le verrouillage coopératif POSIX (POSIX advisory locks) pour implémenter le verrouillage sous Unix. Sous Windows, SQLite utilise les fonctions système LockFile(), LockFileEx() et UnlockFile().
Si les appels systèmes sont fiables pour les systèmes de fichiers natifs sur des disques locaux, ce n’est pas toujours le cas pour toutes les implémentations de systèmes de fichiers, et plus particulièrement pour les systèmes de fichiers réseau.
Une implémentation incorrecte ou boguée du verrouillage de fichier sur le système d’exploitation conduit inévitablement à une corruption de la base de données. En effet, rien n’assure que l’accès EXCLUSIVE l’est vraiment, ni que plusieurs processus n’accèdent pas en concurrence à des données en cours de modification (auquel cas, les données ne sont pas fiables, voire totalement corrompues).
Par exemple, NFS est connu pour avoir des bogues d’implémentation POSIX advisory locks (voire certaines portions non implémentées). Plusieurs blocages ont également été reportés sur le système de fichiers réseau de Windows.
Il est vivement recommandé de ne pas utiliser SQLite sur un système de fichiers en réseau.
SQLite 3 est installé sur le Raspberry en saisissant la commande :
sudo apt-get install sqlite
Ce qui produit le résultat suivant confirmant l’installation du paquet SQLite 3 :
$ sudo apt-get install sqlite
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following extra packages will be installed:
libsqlite0 sqlite3
Suggested packages:
sqlite-doc sqlite3-doc
The following NEW packages will be installed:
libsqlite0 sqlite sqlite3
0 upgraded, 3 newly installed, 0 to remove and 65 not upgraded.
Need to get 238 kB of archives.
After this operation, 638 kB of additional disk space will be used.
Do you want to continue? [Y/n]
....
Preparing to unpack .../libsqlite0_2.8.17-12_armhf.deb ...
Unpacking libsqlite0 (2.8.17-12) ...
Selecting previously unselected package sqlite.
Preparing to unpack .../sqlite_2.8.17-12_armhf.deb ...
Unpacking sqlite (2.8.17-12) ...
Selecting previously unselected package sqlite3.
Preparing to unpack .../sqlite3_3.8.7.1-1+deb8u2_armhf.deb ...
Unpacking sqlite3 (3.8.7.1-1+deb8u2) ...
Processing triggers for man-db (2.7.5-1~bpo8+1) ...
Setting up libsqlite0 (2.8.17-12) ...
Setting up sqlite (2.8.17-12) ...
Setting up sqlite3 (3.8.7.1-1+deb8u2) ...
Processing triggers for libc-bin (2.19-18+deb8u10) …
SQLite est déjà intégré à l’installation standard de Python 2.7. Cela peut facilement être vérifié en saisissant l’instruction import sqlite3 dans une session Python interactive.
pi@pythonic:~ $ python
Python 2.7.9 (default, Sep 17 2016, 20:26:04)
[GCC 4.9.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sqlite3
>>>
L’importation du module sqlite3 doit se dérouler sans erreur.
SQLite propose l’utilitaire sqlite3 qui est un interpréteur de commande pour SQLite 3. L’interpréteur accepte des requêtes SQL (terminées par un point-virgule) et des commandes non SQL (commençant par un point comme .open).
L’interpréteur SQLite présente une particularité, si aucun fichier de base de données n’est communiqué en paramètre au lancement de l’interpréteur de commande, alors une base de données temporaire est automatiquement créée en mémoire. Cette dernière est donc volatile, mais peut également être sauvée à l’aide de la commande .save.
La suite de cette section s’attelle à créer une base de données rudimentaire en utilisant l’interpréteur de commande. Par la suite, cette base de données sera manipulée à l’aide de Python.
Saisir la commande sqlite3 pour démarrer l’interpréteur de commande SQLite. Comme l’indique le message affiché à l’écran, une base de données temporaire est créée en mémoire.
pi@pythonic:~ $ sqlite3
SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite>
La requête suivante crée une table nommée fruits permettant de stocker un nom et le nombre de calories pour 100 g accompagnés d’un identifiant unique id automatiquement incrémenté. Pour rappel, une clé primaire sur une classe de stockage INTEGER devient automatiquement un alias sur le ROWID de la table.
pi@pythonic:~ $ sqlite3
SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> create table fruits (
...> id integer primary key,
...> name text,
...> kcal_100gr integer );
sqlite>
Ensuite, des requêtes SQL permettent d’insérer des données. La requête SQL select permet de constater que les données sont insérées et l’incrémentation de la clé primaire.
sqlite> insert into fruits (name, kcal_100gr) values (’Abricot’, 43 );
sqlite> insert into fruits (name, kcal_100gr) values (’Ananas’, 55 );
sqlite> insert into fruits (name, kcal_100gr) values (’Banane’, 88 );
sqlite> select * from fruits;
1|Abricot|43
2|Ananas|55
3|Banane|88
sqlite>
Pour finir, la base de données peut être sauvegardée à l’aide de la commande .save, puis utiliser .quit pour quitter l’interpréteur SQLite.
sqlite> .save /home/pi/food.db
sqlite> .quit
pi@pythonic:~ $ ls /home/pi/*.db
/home/pi/food.db
pi@pythonic:~ $
Il est également possible d’utiliser une syntaxe alternative en insérant null dans la colonne ROWID (ou sa colonne en alias). Le mécanisme d’auto-incrémentation du ROWID s’active comme attendu.
L’exemple suivant montre comment injecter une commande SQL dans l’interpréteur de commande SQLite ainsi que l’insertion du null dans le champ auto-incrémenté.
pi@pythonic:~ $ echo "insert into fruits values (NULL, ’Kiwi’, 51);" |
sqlite3 /home/pi/food.db
Pour injecter un fichier SQL dans l’interpréteur SQL, il faut utiliser la syntaxe cat mon_fichier.sql | sqlite3 /home/pi/food.db.
La base de données ayant été sauvée dans le répertoire utilisateur sous le nom /home/pi/food.db, il est possible de démarrer l’interpréteur de commande SQLite en indiquant le fichier .db en paramètre (ou en l’ouvrant à l’aide de la commande .open de SQLite).
La commande sqlite3 /home/pi/food.db permet de ré-ouvrir la base de données dans l’interpréteur de commande.
Ensuite, la commande .tables affiche la liste des tables de la base de données. La commande .schema fruits affiche le schéma de la table fruits sous forme d’une requête SQL.
pi@pythonic:~ $ sqlite3 /home/pi/food.db
SQLite version 3.8.7.1 2014-10-29 13:59:56
Enter ".help" for usage hints.
sqlite> .tables
fruits
sqlite> .schema fruits
CREATE TABLE fruits (
id integer primary key,
name text,
kcal_100gr integer );
sqlite> select * from fruits;
1|Abricot|43
2|Ananas|55
3|Banane|88
4|Kiwi|51
sqlite>
Pour finir, SQLite est compatible avec le standard du langage SQL même s’il omet partiellement ou complètement quelques fonctionnalités du langage.
Une documentation du langage SQL tel qu’il est interprété par SQLite 3 est disponible sur les liens suivants :
•.les fonctionnalités omises : https://www.sqlite.org/lang.html
•.les commandes SQL de SQLite : https://www.sqlite.org/lang.html
Les commandes SQLite commencent par un point, comme par exemple .open.
Il est possible d’obtenir une liste de ces commandes à tout moment en saisissant .help dans l’interpréteur SQLite.
Voici quelques commandes SQLite parmi les plus importantes. Une documentation plus complète est disponible sur http://www.sqlite.org/cli.html.
Les paramètres optionnels sont entourés de point d’interrogation « ? »
Commande | Description |
.help | Affiche les commandes supportées par l’interpréteur SQLite. |
.databases | Liste les noms et les fichiers des bases de données attachées à l’interpréteur de commande SQLite. |
.exit | Quitte l’interpréteur de commande SQLite. |
.quit | Quitte l’interpréteur de commande SQLite. |
.open FILE | Ouvre un fichier de base de données et ferme la base de données en cours d’utilisation. |
.save FILE | Sauve la base de données chargée en mémoire dans un fichier de base de données. |
.tables ?LIKE? | Sans paramètre, affiche la liste de toutes les tables de la base de données. Le paramètre LIKE, optionnel, permet de saisir une expression SQL LIKE pour filtrer les tables en fonction de leur nom. Par exemple %ui% permet de lister les tables contenant « ui » dans leur nom. |
.schema ?LIKE? | Permet d’inspecter le schéma de la base de données. Si le paramètre LIKE est mentionné, le schéma est limité aux éléments répondant à l’expression SQL LIKE. Le schéma est retourné sous forme d’une expression SQL CREATE. |
.indices ?LIKE? | Affiche les index de la base de données. Si le paramètre LIKE est mentionné, les index sont limités aux tables répondant à l’expression SQL LIKE. |
.fullschema | Affiche le schéma complet de la base de données. |
.dump ?LIKE? | Décharge le contenu de la base de données (schéma et données) au format SQL vers la sortie standard de l’interpréteur de commande. Le paramètre LIKE, optionnel, permet de saisir une expression SQL LIKE pour filtrer les tables en fonction de leur nom. |
.read FILENAME | Exécute les commandes SQL contenues dans le fichier SQL FILENAME. |
.show | Affiche les paramètres relatifs à la session. Affiche, entre autres, la valeur des séparateurs de colonne et de ligne. |
La sortie peut être redirigée vers un fichier avec les commandes .once ou .output, détaillées ci-dessous.
Cette sortie utilise un séparateur de colonne (| par défaut) et le séparateur de ligne (\r\n par défaut, équivalent du retour chariot suivi d’une nouvelle ligne).
Ces séparateurs sont reconfigurables et utilisés pour toutes les sorties (fichier et terminal), mais aussi l’importation de données.
Voici les commandes permettant de manipuler le format de sortie, d’exportation et d’importation.
Commande | Description |
.header VALUE | Permet d’afficher le nom des colonnes en première ligne de résultat d’une requête SQL de type select. VALUE peut avoir la valeur on ou off (off par défaut). |
.mode VALUE | Permet de modifier le mode d’affichage des résultats des requêtes SQL de type select. Le mode par défaut est list.
|
.separator COL ?ROW? | Permet de modifier le séparateur de colonne par défaut « | » et optionnellement le séparateur de ligne par défaut (\r\n). Ces séparateurs sont utilisés pour le mode de sortie (list) et par la commande .import. |
.output ?FILE? | Envoie la sortie vers un fichier (ou la sortie standard si FILE est omis). |
.once FILE | Envoie la sortie de la commande suivante (uniquement) vers le fichier mentionné dans FILE. |
.import FILE TABLE | Importe un fichier de données au format mentionné par le mode (commande .mode) et les séparateurs (commande .separator) dans la table TABLE. |
Voici quelques exemples de scripts Python permettant d’interagir avec une base de données SQLite3. Ces exemples utilisent la base de données food.db créée à la section précédente.
Une copie de cette base de données est disponible dans le répertoire python/divers/food.db du dépôt GitHub de l’ouvrage.
L’exemple suivant, disponible dans python/divers/test-food-select.py du dépôt GitHub de l’ouvrage indique comment accéder à une base de données SQLite 3 depuis un script Python.
La base de données food.db doit être présente dans le répertoire courant.
L’exemple peut être exécuté à l’aide de la commande python test-food-select.py.
01: # coding: utf-8
02: """ DEMO - Lecture d’une base de données SQLite3.
03:
04: Affiche le contenu de la table fruits (food.db).
05: """
06:
07: import sqlite3 as sqlite
08:
09: dbfile = "food.db"
10:
11: conn = sqlite.connect( dbfile )
12:
13: cursor = conn.cursor()
14:
15: cursor.execute( "select * from fruits order by name")
16: if rowcount == 0:
17: print( "Pas de données disponible" )
18: else:
19: # Affiche le nom des colonnes
20: colnames = [item[0] for item in cursor.description ]
21: print( " | ".join( colnames ) )
22: print( ’-’*40 )
23: # Fetchall() une liste de tuple
24: # [(1, u’Abricot’, 43), (2, u’Ananas’, 55), ... ]
25: for row in cursor.fetchall():
26: # Afficher le données des tuples
27: print( "%s | %s | %s" % (row[0], row[1], row[2]) )
28:
29: print( "\r\r" )
30: # Utiliser fetchone()
31: cursor.execute(
32: """select name,kcal_100gr
33: from fruits
34: where name like ’p%’
35: order by id desc""" )
36: row = cursor.fetchone()
37: while row:
38: # row = (u’Pomme’, 52)
39: print( " | ".join(
40: # transforme tout en string
41: ["%s"%item for item in row])
42: )
43: row = cursor.fetchone()
44:
45:
46: conn.close()
47:
48:
49: if __name__ == "__main__":
50: pass;
•.Ligne 7 : chargement de la bibliothèque sqlite3 (avec l’alias « sqlite »).
•.Ligne 11 : établissement d’une connexion avec la base de données. Le paramètre indique le nom et le chemin d’accès au fichier de base de données SQLite3. Le chemin d’accès n’est pas précisé dans cet exemple, le fichier food.db est donc chargé depuis le répertoire local. À noter que le nom de base de données «:memory:» crée une base de données temporaire en mémoire.
•.Ligne 13 : création d’un curseur. Le curseur permet d’exécuter une requête SQL et de récupérer un ensemble d’enregistrements.
•.Ligne 15 : exécution d’une requête SQL de sélection d’enregistrements dans la table fruits.
•.Ligne 16 : vérifie s’il y a des données avant de les afficher (lignes 18 à 27).
•.Ligne 20 : utilisation d’une expression « List Comprehension » Python pour extraire une liste de noms de colonnes depuis la propriété cursor.description. Voyez la note concernant la « List Comprehension ».
•.Ligne 21 : transforme la liste de noms de colonnes en [’id’, ’name’, ’kcal_100gr’] en assemblant les différents éléments avec un pipe. Ce qui produit ’id | name | kcal_100gr’.
•.Ligne 25 : cursor.fetchall() capture tous les enregistrements sous forme d’une liste de tuples avec un tuple par enregistrement collecté. La structure retournée par fetchall() ressemble à ceci : [(1, u’Abricot’, 43), …, (7, u’P\xeache’, 41)]. À noter que les chaînes de caractères sont du type unicode. L’instruction for permet donc de récupérer, un par un, les tuples d’enregistrements dans la variable row.
•.Ligne 27 : row reprenant tour à tour chaque enregistrement sous forme d’un tuple (par exemple :(1, u’Abricot’) ), row[0] correspond à la colonne id, row[1] à la colonne name et ainsi de suite.
•.Ligne 31 : exécution d’une requête SQL avec une clause SQL like permettant de sélectionner les fruits commençant par la lettre « p ».
•.Ligne 36 : la méthode fetchone() effectue la récupération d’un seul enregistrement sous forme d’un tuple (une entrée par colonne). Dans certains cas, la récupération des données « enregistrement par enregistrement » permet d’éviter la saturation de la mémoire lorsque la sélection couvre un large ensemble de données. La méthode fecthone() retourne None lorsqu’il n’y a pas (ou plus) d’enregistrement disponible.
•.Ligne 37 : la boucle while est exécutée aussi longtemps qu’il y a un enregistrement disponible. En effet, None est évalué à False.
•.Lignes 39-41 : l’utilisation de la « List Compréhension » ["%s"%item for item in row] permet de transformer le tuple avec des types de données divers en liste de chaînes de caractères. Ainsi, (1, u’Abricot’) devient [’1’, u’Abricot’]. N’ayant que des chaînes de caractères dans la liste, l’instruction " | ".join(la_liste) pourra assembler les éléments avec un pipe afin de produire la chaîne u’1 | Abricot’ (chaîne qui sera ensuite affichée avec print() ).
•.Ligne 43 : capture de l’enregistrement suivant.
•.Ligne 46 : fermeture de la connexion avec la base de données.
Dans une approche de puriste, la chaîne de caractères unicode affichée en ligne 39 avec l’instruction print() devrait coder ladite chaîne avec l’encodage du terminal de sorte qu’elle puisse être affichée sans erreur de conversion sur le terminal. Il se trouve que la chaîne est encodée en UTF-8 (cf. stockage DB) et que le terminal de notre Pi est également en UTF-8. Il n’y a donc pas de problème de conversion, cela restant un cas particulier. Dans les autres cas, il faudrait utiliser print( ma_chaine_unicode.encode( encodage_du_terminal, ’ignore’ ) ) sachant que l’encodage du terminal peut être obtenu avec sys.stdout.encoding.
À propos de la List Comprehension
À la ligne 20, l’expression [item[0] for item in cursor.description] permet de transformer une structure complexe en liste de noms de colonnes. Liste qui peut, par la suite, être énumérée avec une instruction for.
Suite à la requête SQL « select * from fruits order by name », la propriété cursor.description retourne la structure suivante :
((’id’, None, None, None, None, None, None), (’name’, None, None, None, None,
None, None), (’kcal_100gr’, None, None, None, None, None, None))
Dans l’expression item[0] for item in cursor.description, item vaut tour à tour (’id’, None, None, None, None, None, None) puis (’name’, None, None, None, None, None, None) et ainsi de suite pour chacun des éléments de cursor.description.
Par conséquent, item[0] vaudra tour à tour ’id’, puis ’name’, puis ’kcal_100gr’.
Pour finir, les caractères [ et ] entourant l’expression reconstituent une liste avec les différents éléments collectés. Ainsi, [item[0] for item in cursor.description] produit le résultat [’id’, ’name’, ’kcal_100gr’].
L’exemple suivant, disponible dans le fichier python/divers/test-food-insert.py du dépôt GitHub de l’ouvrage, indique comment accéder à une base de données SQLite 3 depuis un script Python.
La base de données food.db doit être présente dans le répertoire courant.
L’exemple peut être exécuté à l’aide de la commande python test-food-insert.py.
01: # coding: utf-8
02: """ DEMO - Insertion dans une base de données SQLite3.
03:
04: Insertions d’un enregistrement dans la table fruits
05: (food.db).
06: """
07:
08: import sqlite3 as sqlite
09:
10: # capture un nom et un kcal/100gr
11: fruit_name = raw_input( "Nom fruit: ")
12: fruit_kcal = int( raw_input( "KCal/100gr: ") )
13: reponse = raw_input( ’insérer (o/...)?’ )
14: if reponse.lower().strip() != ’o’:
15: import sys
16: sys.exit(0)
17:
18: dbfile = "food.db"
19:
20: conn = sqlite.connect( dbfile )
21: cursor = conn.cursor()
22:
23: cursor.execute(
24: "insert into fruits (name,kcal_100gr) values (?, ?)",
25: (fruit_name.decode(’UTF-8’), fruit_kcal)
26: )
27: if cursor.rowcount > 0:
28: print( "Enregistrement inséré" )
29: print( "id = %s" % cursor.lastrowid )
30:
31: conn.commit()
32:
33: print( "\r\r" )
34: # Affichage des enregistrements
35: cursor.execute( "select * from fruits order by name" )
36: if cursor.rowcount > 0:
37: colnames = [item[0] for item in cursor.description]
38: print( " | ".join(colnames) )
39: for row in cursor.fetchall():
40: print("|".join(["%s"%value for value in row]))
41:
42: conn.close()
43:
44:
45: if __name__ == "__main__":
46: pass;
L’exemple ci-dessus ne présente que quelques particularités par rapport au point précédent et seules les différences seront mises en lumière :
•.Lignes 11-16 : saisie des données au clavier et demande de confirmation d’insertion. L’instruction sys.exit(0) permet de terminer le script prématurément.
•.Lignes 23-26 : utilisation d’une requête SQL "insert into fruits (name,kcal_100gr) values (?, ?)" avec deux paramètres identifiés par des « ? ». Ces paramètres sont communiqués à l’aide du tuple de valeurs (fruit_name.encode(’UTF-8’), fruit_kcal).
Les paramètres doivent toujours être communiqués sous forme de tuple ! Lorsqu’il n’y a qu’un seul paramètre alors la notation (val,) est utilisée pour forcer la création du tuple.
•.Ligne 25 : fruit_name est une chaîne de caractères. En utilisant Python 2.7, cette dernière n’est donc pas une chaîne de caractères unicode (cf. Python 3.5), il est donc nécessaire de l’encoder en UTF-8 avant de la communiquer au moteur SQLite.
•.Ligne 29 : le champ id de la table étant un alias sur la colonne rowid (auto-incrémentée), il est possible de récupérer cette valeur via cursor.lastrowid pour l’afficher à l’écran.
•.Ligne 31 : par défaut, SQLite utilise un mode auto-commit. Les données sont enregistrées lors de la fermeture de la connexion (ligne 42). Cependant si une erreur survient avant la fermeture de la connexion, les données ne sont pas enregistrées ("commitées") dans la table. L’utilisation de l’instruction conn.commit() permet de précipiter l’opération de commit dans la table et recevoir les éventuelles erreurs au moment du commit. Cela raccourcit également le temps de transaction au strict minimum.
•.Ligne 35 : sélection de tous les enregistrements dans la table.
•.Lignes 37-38 : extraction des noms de colonnes et affichage sous forme de la structure « colonne1 | colonne 2 | colonne 3 ».
•.Ligne 39 : collecte de tous les enregistrements.
•.Ligne 40 : conversion des valeurs en liste de chaînes de caractères à l’aide de ["%s"%value for value in row], puis affichage sous forme de structure « valeur 1 | valeur 2 | valeur 3 ».
•.Ligne 42 : clôture de la connexion. À ce stade, toutes les données non « commitées » sont automatiquement enregistrées dans la base de données.
Dans sa configuration par défaut, la méthode fetchone() du curseur SQL retourne un tuple avec une entrée par colonne sélectionnée. Et la méthode fetchall(), elle, retourne une liste de tuple.
import sqlite3 as sqlite
dbfile = "food.db"
conn = sqlite.connect( dbfile )
cursor = conn.cursor()
cursor.execute(
"""select name,kcal_100gr
from fruits
where name like ’pomme’""" )
row = cursor.fetchone()
# row = (u’Pomme’, 52)
# type( row ) → <type ’tuple’>
nom = row[0]
kcal = row[1]
conn.close()
L’inconvénient de cette approche est que pour obtenir une valeur, il faut utiliser un index pour l’extraction du tuple. Si cela est très économe en mémoire, une erreur d’index ou une modification de la requête SQL retournera une autre valeur que celle attendue !
Modifier le row_factory
SQLite permet de modifier le row_factory, pour que les méthodes fetchall() et fetchone() retournent des objets sqlite3.Row plutôt que des tuples.
import sqlite3 as sqlite
dbfile = "food.db"
conn = sqlite.connect( dbfile )
conn.row_factory = sqlite.Row
cursor = conn.cursor()
cursor.execute(
"""select name,kcal_100gr
from fruits
where name like ’pomme’""" )
row = cursor.fetchone()
# row = (u’Pomme’, 52)
# type( row ) = <type ’sqlite3.Row’>
nom = row[’name’]
kcal = row[’kcal_100gr’]
conn.close()
Cette approche permet d’extraire la valeur d’un champ en utilisant son nom au lieu d’un index. Cette approche offre plus de souplesse, moins de risques d’erreur, mais est aussi moins performante (car il faut résoudre le nom de la colonne).
Voici qui termine l’introduction à SQLite 3, il est temps d’entrer dans le vif du sujet.
Le rôle du script push-to-db.py est de collecter des messages MQTT pour les pousser dans une base de données SQLite3 (pythonic.db).
Le restant du chapitre décrit les différents éléments clés.
Le fonctionnement du script repose sur un fichier de configuration dont les informations sont utilisées pour instancier des objets à la volée. Ce fichier contient les noms de classes à créer, les topics à souscrire et autres informations. La partie logicielle s’appuie sur le fichier de configuration qui à son tour s’appuie sur la partie logicielle. C’est comme un serpent qui se mord la queue, difficile de savoir par quel bout commencer les explications.
Cette section est organisée comme suit :
•.description des éléments logiciels importants (les classes qui seront instanciées à la volée)
•.description détaillée du fichier de configuration
•.détails pratiques de l’exécution
Dans l’approche utilisée pour le stockage des informations :
•.Une table est utilisée pour maintenir une copie du dernier message reçu pour chaque topic souscrit.
•.Une table (ou plusieurs) permet de maintenir un historique des messages reçus pour les topics sélectionnés. Le ou les historiques ne sont maintenus que pour un sous-ensemble des souscriptions.
La table topicmsg est utilisée pour maintenir une copie du dernier message MQTT reçu pour un topic donné. Les messages sont communiqués par le broker MQTT suite aux différentes souscriptions réalisées par push-to-db.py.
Schéma de la table topicmsg (réalisé avec Draw.IO)
Les champs sont les suivants :
•.id : identifiant unique de l’enregistrement, alias sur rowid et donc auto-incrémenté.
•.topic : identification du topic MQTT (ex. : maison/cave/chaufferie/temp-eau). Le champ topic étant couvert par un index unique. Un topic n’apparaît qu’une et une seule fois dans la table topicmsg.
•.message : contenu du message MQTT.
•.qos : qualité de service du message MQTT.
•.rectime : date et heure de la réception du message.
•.tsname : nom de la table timeserie correspondant au topic si une telle table est associée au topic. Cette optimisation permet de savoir facilement s’il existe un historique des données et dans quelle table il se trouve. Si cette approche n’est pas normalisée, l’optimisation sera bien pratique lors du rendu HTML (avec Flask).
Dans un modèle normalisé, le champ topic aurait pu être déclaré comme clé primaire pour la table (et assure aussi l’unicité de chaque enregistrement). Dans les faits, SQLite assurerait l’unicité du champ, mais utiliserait une colonne rowid interne comme clé d’enregistrement. Ce serait également ce rowid qui serait utilisé pour constituer l’arbre B en mémoire. En conséquence, autant utiliser un modèle apparent proche du modèle réel étant donné que SQLite ne constituera pas l’arbre B sur base du topic, mais bien sur le rowid (dont la colonne id est un alias).
Topics collectés
Les topics suivants sont collectés dans la table topicmsg :
•.maison/rez/#
•.maison/exterieur/#
•.maison/cave/#
Les tables ts_xxx sont destinées à maintenir des historiques des valeurs communiquées pour un ou plusieurs topics. Ces tables pouvant devenir relativement volumineuses en fonction de la fréquence et de la quantité des messages capturés. Plusieurs optimisations ont été prévues afin de ne pas surcharger la carte SD du Raspberry Pi (en termes de taille de fichier et d’opérations de lecture/écriture).
•.Optimisation 1 : prévoir une opération pour tronquer régulièrement la table historique (timeserie) sur base d’un nombre maximum d’enregistrements dans celle-ci. Cela revient à éliminer les enregistrements les plus anciens.
•.Optimisation 2 : répartition des historiques dans différentes tables timeseries pour répartir logiquement l’information. Par exemple, la table ts_salon pour l’historique salon, ts_cab pour l’historique cabane, etc. Cette approche n’est pas normalisée du point de vue base de données, mais (1) limite le nombre d’opérations en lecture sur la carte SD pour atteindre un échantillon d’historique spécifique, (2) permet de maintenir des historiques particuliers plus grands et plus longtemps (ex. : données météos) sans complexifier l’opération tronquage, (3) permet de séparer les historiques grandissants rapidement - donc tronqués régulièrement - des historiques évoluant lentement. De la sorte, il est possible d’éviter le tronquage prématuré de données pertinentes poussées à l’arrière d’un l’historique par une accumulation rapide d’autres données moins pertinentes.
Voici le schéma général d’une table timeseries ts_xxx où xxx est remplacé par un nom identifiant clairement le contenu de la table.
Schéma de la table ts_xxx (réalisé avec Draw.IO)
Les champs sont les suivants :
•.id : voir la table topicmsg. Trier les enregistrements par ordre décroissant permet d’obtenir le message le plus récent en premier.
•.topic : identification du topic MQTT (ex. : maison/cave/chaufferie/temp-eau). Attention : contrairement à la table topicmsg, le contenu de ce champ n’est plus unique.
•.message : voir table topicmsg.
•.qos : voir table topicmsg.
•.rectime : voir table topicmsg. Ne pas trier la table sur ce champ, utiliser plutôt le champ id qui sera beaucoup plus performant tout en offrant le même résultat.
Topics collectés
Les topics suivants sont collectés dans des tables historiques.
Pour la table ts_cab destinée à recevoir l’historique des données météorologiques de la cabane de jardin :
•.maison/exterieur/cabane/lux : mesure de la luminosité
•.maison/exterieur/jardin/hrel : mesure de l’humidité relative
•.maison/exterieur/jardin/temp : mesure de la température
Pour la table ts_salon destinée à recevoir l’historique du salon
•.maison/rez/salon/temp : mesure de la température
•.maison/rez/salon/pir : mesure de l’activité humaine
Pour la table ts_chauf destinée à recevoir l’historique de la chaufferie
•.maison/cave/chaufferie/etat : modification d’état de la chaufferie
•.maison/cave/chaufferie/temp-eau : relevé de la température du circuit d’eau
Le logiciel est architecturé de sorte à :
•.Supporter plusieurs types de capture MQTT (capture de Topic ou capture de TimeSerie) avec la possibilité de définir d’autres types de captures.
•.Supporter plusieurs types de stockage par l’intermédiaire d’une classe Connector offrant les services de mise à jour de valeurs de Topic et d’ajout de valeurs dans les TimeSerie. SqliteConnector offre les services de stockage pour SQLite. Rien n’empêche d’ajouter une autre classe Connector permettant d’effectuer un stockage dans une base de données MySQL, voire dans un simple fichier Excel.
•.Avoir une configuration complète dans un fichier d’initialisation (.ini). Il contient des informations nécessaires pour réaliser les souscriptions MQTT, utiliser les classes de stockage adéquates (MqttTopicCapture ou MqttTimeSerieCapture) avec le Connector adéquat (SqliteConnector).
Classes de traitement des captures MQTT et de stockage des messages (réalisé avec Draw.IO)
SqliteConnector
La classe SqliteConnector (héritée de BaseConnector) a pour seule tâche d’offrir les services nécessaires au stockage d’un message MQTT (la paire topic et payload) sur un média que le connecteur est capable de manipuler. SqliteConnector sait donc comment accéder à une base de données Sqlite3.
Les services offerts sont :
•.update_value(...) : qui met à jour une table donnée pour un topic spécifique. Enregistre la valeur du message MQTT (aussi appelé payload) comme dernière valeur connue pour le topic spécifié. Cela se traduit principalement par une requête SQL de type « update topicmsg set message = ? where topic = ? » avec les informations communiquées par une classe de capture de valeur de topic (MqttTopicCapture en l’occurrence, voir ci-dessous).
•.timeserie_append(…) : qui permet d’ajouter, dans une table d’historique, une nouvelle valeur de message. Cela se traduit par une requête SQL de type « insert into ts_salon values (topic, message, ...) ».
Les services update_value et timeserie_append prennent le nom de la table comme premier paramètre. L’information est fournie par l’appelant qui sait, en fonction de sa configuration, dans quelle table les informations doivent être stockées. Il est en effet tout à fait envisageable que des valeurs de consommation électrique, eau, gaz soient volontairement séparées des informations de surveillance générale.
Ces services sont épaulés par des méthodes permettant de manipuler le média.
•.__ini__(params) : fonction d’initialisation de la classe. Le paramètre params contient un dictionnaire clé=valeur avec toutes les entrées du fichier .ini pour la section correspondant à la classe connecteur SqliteConnector. Cette approche permet de placer dans le fichier .ini tous les paramètres nécessaires au bon fonctionnement du connecteur.
[connector.sqlitedb]
class=SqliteConnector
db=/var/local/sqlite/pythonic.db
•.connect() : permet au connecteur de se connecter sur la source de données (SQLite 3).
•.disconnect() : permet au connecteur de se déconnecter de la base de données.
•.commit() : permet d’écrire physiquement les informations dans la base de données. Équivalent d’une opération de sauvegarde lors d’opérations sur un fichier.
Il est tout à fait possible d’envisager l’élaboration d’une autre classe de service de stockage comme CalcConnector. Cette dernière ouvrirait un fichier LibreOffice Calc, équivalent de Microsoft Excel, pour y stocker les informations. Dans ce cadre, les méthodes connect(), disconnect(), commit() permettent respectivement d’ouvrir, fermer, sauver le fichier Calc, alors que update_value()et timeserie_append() effectuent respectivement la mise à jour d’une valeur et l’ajout de valeurs dans un historique en utilisant le nom de la feuille calcul en guise de nom de table.
L’implémentation de SqliteConnector se réduit aux quelques éléments suivants, sans oublier import sqlite3 as sqlite en début de script.
class SqliteConnector( BaseConnector ):
""" Connecteur pour DB Sqlite3 """
def __init__( self, params ):
super( SqliteConnector, self ).__init__( params )
# fichier de stockage de la DB
# typiquement /var/local/sqlite/pythonic.db
self.db_file = params[’db’]
# reference vers le moteur DB
self._conn = None
def is_connected( self ):
return (self._conn != None)
def connect( self ):
if not self.is_connected():
self._conn = sqlite.connect( self.db_file )
def disconnect( self ):
if self.is_connected():
self._conn.close()
self._conn = None
def commit( self ):
if self.is_connected():
self._conn.commit() # sauver les modifications
def update_value( self, table_name, receive_time, topic,
payload, qos ):
""" Stocke la dernière valeur connue dans la table """
sSql = "UPDATE %s SET message = ?, qos = ?, rectime = ? where topic = ?" % table_name
self.connect()
cur = self._conn.cursor()
r = cur.execute( sSql, (payload, qos, receive_time, topic) )
# Si record pas encore mis-à-jour --> insérer
if r.rowcount == 0:
sSql = "INSERT INTO %s (topic,message,qos,rectime) VALUES (?,?,?,?)" % table_name
r = cur.execute( sSql, (topic,payload,qos,receive_time) )
def timeserie_append( self, table_name, receive_time, topic,
payload, qos ):
""" Stocke l’historique de valeurs (timeseries) """
sSql = "INSERT INTO %s (topic,message,qos,rectime) VALUES (?,?,?,?)" % table_name
self.connect()
cur = self._conn.cursor()
r = cur.execute( sSql, (topic,payload,qos,receive_time) )
MqttBaseCapture
MqttBaseCapture est une classe de base destinée à être spécialisée, comme c’est le cas pour les classes MqttTopicCapture et MqttTimeserieCapture.
Classe MqttBaseCapture et ses descendants (réalisé avec Draw.IO)
Une classe de capture permet :
•.de capturer un ou plusieurs messages MQTT particuliers (sur la base de filtres MQTT),
•.d’effectuer une opération d’écriture, de sauvegarde, ou de mise à jour à l’aide d’un connecteur (classe SqliteConnector),
•.suivant la classe de capture dérivée de MqttBaseCapture, l’opération effectuée sera une mise à jour de la valeur existante (capture MQTT) ou un ajout de valeur dans une table d’historique (timeserie MQTT),
•.de déterminer la table dans laquelle l’information sera stockée, grâce aux informations provenant du fichier de configuration.
L’opération de stockage proprement dite est prise en charge par la méthode store_data(...) qui accepte un message MQTT en paramètre. Le message MQTT est stocké dans une instance de la classe QueuedMessage. En effet, les messages MQTT sont temporairement empilés dans une queue FIFO en attente du traitement pour le stockage.
L’opération de stockage n’est pas implémentée dans la classe MqttBaseCapture, mais dans les classes dérivées MqttTopicCapture et MqttTimeserieCapture qui savent comment manipuler le connecteur (SqliteConnector) pour stocker l’information afin de réaliser le service de capture requis.
La classe MqttBaseCapture expose les propriétés et méthodes suivantes :
•.connector : le connecteur (dérivé de BaseConnector, SqliteConnector en l’occurrence) à utiliser pour effectuer l’opération de stockage.
•.storage_table : le nom de la table où doit être stockée l’information capturée. Cette information provient du fichier de configuration (voir ci-dessous).
•.sub_filters : liste contenant les expressions de filtrages MQTT (string), donc des souscriptions MQTT effectuées sur le broker, pour lesquelles l’opération de stockage doit être effectuée. L’opération de stockage de la capture de topic/valeur ou de la capture d’historique est prise en charge par une classe dérivée de MqttBaseCapture (MqttTopicCapture ou MqttTimeserieCapture).
•.sub_regexps : liste correspondant à sub_filters, mais contenant les expressions de filtrage sous forme d’expressions régulières. De la sorte, les caractères « + » et « # » des expressions de filtrage sont remplacés dans les expressions régulières. L’utilisation d’expressions régulières facilite le test de correspondance avec les topics en provenance du broker. À noter qu’un topic (ex. : maison/salon/temp) peut correspondre à plusieurs souscriptions (ex. : maison/+/temp et maison/#), mais qu’il ne faudra déclencher le stockage avec store_data(…) qu’une seule fois pour ce topic !
•.__init__( subscribe_comalist, storage_target, connector ) : initialise la classe de capture avec la liste des souscriptions MQTT qui la concernent, la table de destination (storage_target) et le connecteur à utiliser pour réaliser le stockage. Ces deux dernières informations sont déduites du fichier de configuration.
[mqtt.capture.1]
subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/
exterieur/jardin/temp
class=MqttTimeserieCapture
storage=sqlitedb.ts_cab
•.store_data( queued_message ) : réalise le stockage du message queued_message. Cette fonction n’a pas d’implémentation sur MqttBaseCapture, ce sont les classes dérivées qui implémentent l’opération de stockage.
•.mqtt_filter_to_re( sub_filter ) : permet de transformer une expression de filtrage MQTT, utilisée pour la souscription, en expression régulière.
•.match_souscription( topic ) : permet de vérifier si le topic soumis en paramètre (message communiqué par le broker suite aux souscriptions) correspond à l’une des expressions de filtrage de la classe de capture. Les expressions régulières disponibles dans la liste sub_regexps sont utilisées pour réaliser efficacement les comparaisons.
•.target_id() : retourne un identifiant unique pour identifier la destination de sauvegarde. Ce dernier est composé du nom de classe du connecteur et de la table de stockage (ex. : sqliteconnector.ts_salon). Le script utilisant un fichier de configuration, il est théoriquement possible de définir plusieurs captures (ex. : sur la base MqttTimeserieCapture) couvrant partiellement les mêmes topics et destinés à être stockés dans la même table. Le test sur target_id permet de détecter et d’éviter ces duplicatas d’opérations de stockage à la fois polluants dans les tables historiques et redondants dans les tables de capture.
MqttTopicCapture
Le but de la classe MqttTopicCapture est de maintenir une copie de la dernière valeur reçue pour les topics souscrits.
L’implémentation de store_data() dans MqttTopicCapture est assez concise.
class MqttTopicCapture( MqttBaseCapture ):
""" Classe gérant la capture des messages et
stockage de la dernière valeur dans une table """
def __init__( self, subscribe_comalist,
storage_target, connector ):
super( MqttTopicCapture, self).__init__(
subscribe_comalist, storage_target, connector )
def store_data( self, queued_message ):
""" traite le message capturé pour l’enregistrer à
l’aide du connecteur! """
# Le connecteur sait comment accéder à la table
self.connector.update_value( self.storage_table ,
queued_message.receive_time,
queued_message.topic,
queued_message.payload,
queued_message.qos )
MqttTimeserieCapture
Le but de la classe MqttTimeserieCapture est d’ajouter une valeur dans un historique existant.
L’implémentation de store_data() est également très concise.
class MqttTimeserieCapture( MqttBaseCapture ):
""" Classe gérant la capture des messages et stockage de la
dernière valeur dans une table """
def __init__( self, subscribe_comalist,
storage_target, connector ):
super( MqttTimeserieCapture, self).__init__(
subscribe_comalist, storage_target, connector )
def store_data( self, queued_message ):
""" traite le message capturé pour l’enregistrer à l’aide
du connecteur!"""
# Le connecteur sait comment accéder à la table
self.connector.timeserie_append( self.storage_table,
queued_message.receive_time,
queued_message.topic,
queued_message.payload,
queued_message.qos )
Avant de poursuivre avec le diagramme des classes et les explications concernant le fonctionnement global du script, il est important de préciser quelques éléments relatifs au fichier de configuration.
Le fichier de configuration utilise le format inifile rendu très populaire par le système d’exploitation Windows. Ce format scinde le fichier en sections, chaque section portant un nom entouré de crochets (ex. : [lazywriter] ). La section contient une ou plusieurs valeurs nommées et organisées sous la forme clé=valeur . (ex. : MaxQueueSize=10).
Le fichier de configuration est exploité pour instancier des objets à la volée, objets dont le nom de classe se trouve précisément repris dans le fichier de configuration.
Le script push-to-db.py utilise le fichier de configuration /etc/pythonic/push-to-db.ini pour configurer le fonctionnement du logiciel.
Celui-ci permet de définir :
•.les instances de classe connecteur (et paramètres) à utiliser,
•.des souscriptions et instances de classe de capture associées (MqttXxxxCapture).
L’exemple ci-dessous reprend une partie du fichier de configuration :
[connector.sqlitedb]
class=SqliteConnector
db=/var/local/sqlite/pythonic.db
[mqtt.capture.0]
subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#
class=MqttTopicCapture
storage=sqlitedb.topicmsg
[mqtt.capture.1]
subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/
exterieur/jardin/temp
class=MqttTimeserieCapture
storage=sqlitedb.ts_cab
[mqtt.capture.2]
subscribe=maison/rez/salon/temp,maison/rez/salon/pir
class=MqttTimeserieCapture
storage=sqlitedb.ts_salon
[mqtt.capture.3]
subscribe=maison/cave/chaufferie/etat,maison/cave/chaufferie/temp-eau
class=MqttTimeserieCapture
storage=sqlitedb.ts_chauf
Les connecteurs
Les connecteurs sont définis dans des sections commençant par la chaîne « connector. » suivie d’un code d’identification (« sqlitedb » dans ce cas). Les connecteurs permettent d’accéder aux médias de stockage et offrent les services update_value(...) et timeserie_append(...) précédemment décrits.
La section [connector.sqlitedb] ci-dessous définit un connecteur nommé « sqlitedb » dans la configuration.
[connector.sqlitedb]
class=SqliteConnector
db=/var/local/sqlite/pythonic.db
La configuration du connecteur précise la classe à utiliser (SqliteConnector) avec le paramètre class= ainsi que les paramètres à passer au constructeur de la classe (db=...). Les paramètres sont passés au constructeur sous forme de dictionnaire, il peut donc y avoir autant de paramètres que souhaité, qui doivent respecter la nomenclature clé=valeur.
Une fois le connecteur défini, son identifiant « sqlitedb » peut ensuite être utilisé dans la configuration des souscriptions.
Les souscriptions
Les souscriptions sont définies dans des sections commençant par la chaîne « mqtt.capture. » suivie d’un code d’identification.
Les sections de souscriptions définissent à la fois :
•.les souscriptions MQTT à effectuer,
•.la classe de capture à utiliser (MqttTopicCapture ou MqttTimeserieCapture) pour traiter la demande de capture,
•.le connecteur et la table à utiliser (sous la forme <identifiant-connecteur>.<nom-de-table> ).
L’exemple ci-dessous reprend la capture d’une série de messages MQTT pour le stockage de la dernière valeur connue (dans la table topicmsg de la base de données identifiée par « sqlitedb »).
[mqtt.capture.0]
subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#
class=MqttTopicCapture
storage=sqlitedb.topicmsg
Les messages à capturer sont repris dans le paramètre subscribe=. Les différentes souscriptions y sont séparées par une virgule.
Le paramètre class= repend le nom de la classe à instancier pour prendre les messages souscrits en charge.
Pour finir, le paramètre storage= permet de mentionner le connecteur à utiliser (sqlitedb fait référence à la section [connector.sqlitedb] du fichier de configuration) et la table où l’information doit être stockée (la table topicmsg). Le nom de la table est utilisé par la classe MqttTopicCapture (cf. paramètre class=) pour réaliser le stockage du message.
Cet autre exemple reprend la capture de messages MQTT pour le stockage dans une table d’historique :
[mqtt.capture.1]
subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/
exterieur/jardin/temp
class=MqttTimeserieCapture
storage=sqlitedb.ts_cab
Le paramètre class= mentionne la classe MqttTimeserieCapture (classe dédiée au stockage d’historique).
Le paramètre storage= mentionne le nom de la table ts_cab (timeserie cabane) à l’aide du connecteur décrit dans la section [connector.sqlitedb].
Les topics à capturer sont mentionnés dans le paramètre subscribe= (liste séparée par des virgules).
Organisation de l’application push-to-do (réalisé avec Draw.io)
Le diagramme ci-dessus présente l’organisation générale du script push-to-db.py. Il est constitué des éléments suivants :
•.ClasseApp : classe applicative qui prend en charge les aspects importants du logiciel comme le chargement du fichier de configuration, la création des objets et les mécanismes de synchronisation nécessaires. La classe App organise les tâches.
•.ClasseQueuedMessage : classe destinée à maintenir une copie d’un message MQTT reçu par l’application. Les messages en attente de traitement (les instances de QueuedMessage) sont empilés dans la message_queue.
•.ClasseMessageLazyWriter : un thread, en tâche de fond, prenant en charge l’exécution des opérations d’écriture en base de données.
QueuedMessage
La classe QueuedMessage est utilisée pour stocker le contenu d’un message MQTT envoyé par le broker.
Une instance de la classe est créée pour chaque message MQTT reçu et devant être traité par une des classes dérivées de MqttBaseCapture (voir fichier de configuration ci-avant). Si un même message doit être traité par plusieurs classes MqttBaseCapture (par exemple MqttTopicCapture et MqttTimeserieCapture), alors plusieurs instances de QueuedMessage sont créées pour ce même message.
Les instances sont empilées dans une queue FIFO (First In First Out, premier entré premier sorti) jusqu’au moment du stockage de ceux-ci par le thread MessageLazyWriter.
class QueuedMessage( object ):
__slots__ = [’receive_time’, ’topic’, ’payload’,
’sub_handler’, ’qos’]
def __init__(self, receive_time, topic, payload,
qos, sub_handler ):
self.receive_time = receive_time
self.topic = topic
self.payload = payload
self.qos = qos
# Une des classes MqttXxxCapture capable
# d’écrire le message en DB en utilisant son connecteur.
self.sub_handler = sub_handler
•.__slots__ : la classe utilise la définition de __slots__ pour optimiser le stockage en mémoire. De la sorte, les attributs ne sont pas créés dynamiquement, leur définition est statique.
•.receive_time : heure à laquelle le message a été reçu ( datetime.now() ).
•.topic, payload, qos : respectivement, le topic, le contenu du message et la qualité de service du message.
•.sub_handler : l’attribut sub_handler (subscription handler) est utilisé pour maintenir une référence vers une instance de MqttTopicCapture ou MqttTimeserieCapture destinée à prendre en charge l’opération de stockage. En effet, la classe applicative App maintient une liste des sub_handlers disponibles, ce qui permet de déterminer le(s) sub_handler(s) applicable(s) à la réception d’un nouveau message MQTT. Le contenu du message et le sub_handler identifié sont alors mémorisés dans une nouvelle instance QueuedMessage.
Comme indiqué dans le diagramme des classes, l’attribut sub_handler contient une référence vers une instance dérivée de MqttBaseCapture, instance destinée à réaliser l’opération de stockage pour le message MQTT reçu. MqttBaseCapture dispose d’un attribut connector (comme par exemple une instance SqlConnector) permettant d’accéder facilement aux outils de stockage. La présence de l’attribut sub_handler dans QueuedMessage simplifiera, par la suite, le code nécessaire pour exécuter l’opération de stockage du message.
Queue de QueuedMessage
Un petit aparté concernant la queue FIFO utilisée pour stocker les messages QueuedMessage.
Cette queue créée par la classe applicative App utilise les extraits de code suivants :
from Queue import Queue
...
self.message_queue = Queue()
Le module Queue (renommé queue en Python3) implémente plusieurs types de queues avec la particularité de mettre en place toute la sémantique nécessaire permettant de gérer le verrouillage, ce qui permet d’échanger des informations en toute sécurité entre plusieurs threads (exécution multitâche). Le thread principal (processus) peut donc empiler des éléments en toute sécurité pendant que le thread MessageLazyWriter dépile les messages de la queue.
Le module Queue propose les classes suivantes :
•.Queue : une queue FIFO (First In First Out) où le premier élément inséré dans la queue sera également le premier à sortir de la queue. C’est le type de queue utilisée pour stocker les instances de QueuedMessage.
•.LifoQueue : une queue LIFO (Last In First Out) où le dernier élément inséré dans la queue sera le premier élément à en sortir.
•.PriorityQueue : une queue où le premier élément à sortir est la plus petite valeur disponible dans la queue.
MessageLazyWriter
Le thread MessageLazyWriter a pour tâche de surveiller la taille de la queue des messages et de lancer les opérations d’écriture à intervalle régulier en respectant diverses règles.
Le but du thread est de retarder la sauvegarde des messages pour permettre l’accumulation de ceux-ci avant de réaliser une opération d’écriture sur le support physique. Pour l’anecdote, le jeu de mot « Lazy Writer » signifie « écrivain paresseux ».
En effet, les messages MQTT arrivent sous forme d’un flux sporadique avec, ici et là, des communications de messages MQTT en salve lorsqu’un objet communique plusieurs relevés. Il y a donc un intérêt à accumuler quelques messages pour réduire le nombre d’opérations d’écritures intermittentes, car chacune d’entre elles peut potentiellement être bloquante (ex. : base de données SQLite), ce qui entrave des accès concurrents aux données stockées. Dans le cas de SQLite, il est préférable de concentrer les écritures en bloc pour minimiser le temps de blocage en écriture, temps durant lequel toute opération de lecture est interdite.
D’autre part, le stockage d’une base de données sur le système de fichiers de la carte SD représente un goulot d’étranglement pour les performances du système. S’il n’est pas possible d’opter pour un stockage sur un disque externe, alors il est préférable de réduire les opérations d’écriture sporadiques pour minimiser l’impact sur les performances générales du système.
Enfin, une carte SD dispose d’un nombre limité de cycles d’écritures par cellule. Bien que cette limite soit très élevée (approximativement 100 000 cycles d’écritures), cette dernière est atteignable plus ou moins rapidement par les systèmes informatiques. Encore une fois, différer l’écriture des messages permet de réunir un lot de modifications/altérations en une seule opération physique, ce qui réduit sensiblement le nombre de cycles d’écritures nécessaires sur le support physique.
Le thread MessageLazyWriter utilise les paramètres suivants du fichier de configuration /etc/pythonic/push-to-db.ini.
[lazywriter]
MaxQueueLatency=120
MaxQueueSize=10
PauseAfterProcess=2
Paramètres MessageLazyWriter dans le fichier de configuration :
•.MaxQueueLatency : ce paramètre fixe le temps maximum de latence avant l’écriture d’un message en base de données. Par défaut, cette valeur est fixée à 120 secondes (2 minutes). Lorsque la queue des messages est vide et qu’un premier message apparaît, alors MessageLazyWriter commence à égrainer le temps de latence. L’opération d’écriture est lancée une fois ce temps écoulé. Cela permet à d’autres messages d’être réceptionnés dans l’intervalle avant de lancer l’opération de stockage. Réduire le temps de latence permet d’augmenter la fréquence de mise à jour des données, mais réduit dans la même proportion les opportunités d’accès concurrent sur la base de données SQLite. Cent vingt secondes de latence est un délai très conséquent, mais encore relativement raisonnable dans le cadre d’une base de données stockée sur une carte SD, cela limite les écritures à 720 cycles par jour si la quantité de messages est relativement faible.
•.MaxQueueSize : indique le nombre maximum de messages présents dans la queue à partir duquel l’opération d’écriture doit être provoquée, peu importe le temps de latence. Cela n’empêche pas l’ajout d’autres messages dans la queue, messages qui seront également stockés dans la foulée. MaxQueueSize permet de limiter l’impact du temps de latence sur le stockage en provoquant l’opération de stockage s’il y a assez de données en attente. Fixée à 10, la valeur de MaxQueueSize est convenable pour une capture sur un broker faiblement chargé avec une communication épisodique. La valeur de MaxQueueSize devra être augmentée s’il y a de très nombreux messages capturés afin de ne pas tomber dans un excès de cycle d’écritures rendant caduque l’utilité de la valeur de MaxQueueLatency.
•.PauseAfterProcessing : indique un temps de pause (en secondes) volontaire après le traitement de la queue des messages. Ce temps de pause vise à offrir une opportunité d’accès en lecture sur la base de données dans le cas où le thread MessageLazyWriter serait noyé sous un flot de messages (leur nombre atteignant ainsi chaque fois la valeur MaxQueueSize).
Un temps de latence MaxQueueLatency important peut représenter un « problème » lors de la communication de messages urgents tels que des alertes. Pour rappel, push-to-db.py réalise un stockage persistant de certaines informations, sa vocation n’est pas de remplacer le broker MQTT. Si un message d’alerte doit être communiqué rapidement à un processus, alors il est préférable que ledit processus effectue les souscriptions adéquates directement sur le broker MQTT afin d’être immédiatement alerté par le broker.
Voici les détails de la classe MessageLazyWriter.
01: class MessageLazyWriter(threading.Thread):
02: def __init__( self, params, message_queue, connectors,
03: stopper_event ):
04: super( MessageLazyWriter, self ).__init__()
05: self.max_queue_latency =
06: int(params[’maxqueuelatency’])
07: self.max_queue_size = int(params[’maxqueuesize’])
08: self.pause_after_process =
09: int(params[’pauseafterprocess’])
10: # Queue FiFo synchronisée
11: self.message_queue = message_queue
12: # Liste des connecteurs
13: self.connectors = connectors
14: # Event pour signaler l’arrêt du thread
15: self.stopper_event = stopper_event
16:
17: self.logger = logging.getLogger(’root’)
18:
19: def run( self ):
20: self.logger.debug( ’LazyWriter thread started’)
21: # heure debut compte-à-rebours
22: latency_start = None
23:
24: while not self.stopper_event.is_set():
25: if self.message_queue.empty():
26: latency_start = None
27: continue
28: else:
29: if latency_start == None:
30: self.logger.debug( ’LazyWriter latency_start set
31: to now’)
32: latency_start = datetime.datetime.now()
33:
34: if self.message_queue.qsize() > self.max_queue_size:
35: self.logger.info(
36: ’LazyWriter queue size %s reached ->
37: Process_message_queue.’
38: % self.max_queue_size )
39: self.process_message_queue()
40: latency_start = None
41: time.sleep( self.pause_after_process )
42: elif latency_start and (
43: (datetime.datetime.now()-latency_start
44: ).seconds > self.max_queue_latency ):
45: self.logger.info(
46: ’LazyWriter latency %s sec reached ->
47: Process_message_queue.’
48: % self.max_queue_latency )
49: self.process_message_queue()
50: latency_start = None
51: time.sleep( self.pause_after_process )
52:
53: self.logger.debug( ’LazyWriter thread exit’)
54:
55: def process_message_queue( self ):
56: con_list = []
57: pmq_logger = logging.getLogger(’pmq’)
58: while not self.message_queue.empty():
59: queued_message = self.message_queue.get()
60: try:
61: pmq_logger.debug( ’process_message_queue %s ’ +
62: ’for handler %s on %s with payload %s’ %
63: (queued_message.topic,
64: queued_message.sub_handler.__class__.__name__
65: ,
66: queued_message.sub_handler.target_id(),
67: queued_message.payload) )
68: # Collecter les connecteur mis-en-oeuvre
69: connector = queued_message.sub_handler.connector
70: if not connector in con_list:
71: con_list.append( connector )
72: queued_message.sub_handler.store_data(
73: queued_message )
74: except Exception as err:
75: pmq_logger.error(
76: ’process_message_queue encounter an’ +
77: ’ error while processing the message’ )
78: pmq_logger.error( ’ %s with %s’ % (
79: err.__class__.__name__, err) )
80: pmq_logger.error( ’ handler: %s’ %
81: queued_message.sub_handler.__class__.__name__ )
82: pmq_logger.error( ’ topic : %s’ %
83: queued_message.topic )
84: pmq_logger.error( ’ payload: %s’ %
85: queued_message.payload )
86: finally:
87: self.message_queue.task_done()
88: # Faire un commit sur tous les connecteurs
89: for connector in con_list:
90: connector.commit()
91: connector.disconnect()
Méthode __init__( self, params, message_queue, connectors, stopper_event )
•.Ligne 2 : initialisation du thread MessageLazyWriter avec la méthode __init__. Le paramètre params est un dictionnaire contenant les valeurs de la section [lazywriter] du fichier d’initialisation. Le paramètre message_queue est une référence vers la queue FIFO contenant les instances QueuedMessage. Cette référence, partagée par la classe applicative, permet au thread de récupérer les messages MQTT collectés par l’application. Le paramètre connectors est une référence vers un dictionnaire d’instances de BaseConnector (partagé par la classe applicative), stopper_event permet à l’application d’envoyer un signal « fin d’exécution » au thread (ce point sera abordé plus loin).
•.Ligne 4 : appel de la méthode d’initialisation de l’ancêtre (de la classe Thread).
•.Lignes 5 à 9 : récupération des paramètres depuis le dictionnaire params (donc depuis la section [lazywriter]du fichier d’initialisation.
•.Lignes 11 à 15 : mémorisation des différentes références pour un usage ultérieur.
•.Ligne 17 : récupération du logger principal (appelé « root ») à l’aide du module logging. L’utilisation et la configuration du logger seront abordées en détail plus loin dans le chapitre.
Méthode run( self )
•.Ligne 19 : méthode run() appelée lors du démarrage du thread et surchargée dans la classe dérivée MessageLazyWriter.
•.Ligne 20 : envoi d’un message de débogage dans le logger principal.
•.Ligne 23 : latency_start contient l’heure à laquelle le premier message est détecté dans la queue. Cela permet de commencer le décompte des 120 secondes au terme desquelles les messages de la queue seront poussés dans la base de données. La variable latency_start contient None (rien) lorsqu’il n’y a aucun message détecté dans la queue et qu’aucun compte à rebours n’est actif.
•.Ligne 24 : boucle while qui ne s’arrête que lorsque l’événement stopper_event est activé/signalé. Cet événement est « signalé » par la classe App dans le thread principal du script.
•.Lignes 25 à 27 : remise à None de latency_start si la queue est vide, car il n’y a rien à faire. Si la queue n’est plus vide, alors c’est le bloc de lignes 29 à 51 qui est exécuté.
•.Lignes 29 à 32 : Si la queue n’est pas vide et que latency_start n’est pas initialisé alors c’est qu’un message vient d’arriver dans la queue. Début du compte à rebours en initialisant la variable latency_start avec l’heure système à l’aide de datetime.datetime.now(). Le logger est également utilisé pour envoyer un message de débogage signalant le début du temps de latence.
•.Ligne 34 : si la taille de la queue dépasse 10 éléments (tel que c’est mentionné dans le fichier de configuration), alors pousser les messages dans la base de données. Sinon (ligne 42) vérifier si le temps de latence n’est pas échu, auquel cas il faut également pousser les messages dans la base de données.
•.Lignes 35 à 41 : la queue a dépassé la quantité maximale empilable. Le logger est utilisé pour envoyer un message d’information ( logger.info(…) ). Ensuite, le contenu de la queue est pris en charge par la méthode process_message_queue(). Enfin, le temps de latence latency_start est remis à None (car il n’y a plus d’élément dans la queue). Enfin, le temps de pause ( time.sleep() ) est utilisé pour imposer un temps de latence entre les salves d’opérations d’écriture et favoriser ainsi les opérations concurrentes. Si la queue était à nouveau remplie avec de nombreux messages, un nouveau cycle d’écriture serait exécuté dans la foulée.
•.Ligne 42 : test si le temps de latence est échu, auquel cas une opération d’écriture est également exécutée. Dans le test, l’expression « if latency_start and » permet de vérifier si la variable latency_start est bien initialisée (ce test est faux si latency_start égale None). L’expression « datetime.datetime.now()-latency_start » évalue le temps écoulé depuis latency_start. Cette opération retourne un type timedelta qui représente une différence de temps. Cette différence de temps est stockée en interne avec les informations jours, secondes et microsecondes. Pour obtenir le temps en secondes, il faut appeler la propriété seconds de timedelta (le délai n’atteignant jamais le jour). D’où l’expression finale « (datetime.datetime.now()-latency_start).seconds » dont le résultat est comparé à max_queue_latency (valeur en secondes provenant du fichier de configuration).
•.Lignes 45 à 51 : le temps de latence a dépassé le temps maximal et les messages doivent être poussés vers la base de données de façon similaire aux lignes 35 à 41.
•.Ligne 53 : message de débogage indiquant la fin d’exécution du thread. Cela n’arrive que si la boucle while termine son exécution et donc uniquement si l’événement stopper_event est signalé.
Méthode process_message_queue( self )
•.Cette méthode traite tous les messages (les instances de QueuedMessage) empilés dans la queue de messages en utilisant la classe de capture (dérivée de MqttBaseCapture) et le connecteur (BaseConnector) afin d’enregistrer les données (cf. Diagramme des classes dans ce chapitre).
•.Ligne 56 : création d’une liste intitulée con_list destinée à recevoir les connecteurs utilisés durant le traitement de la queue de messages. Chaque connecteur utilisé au moins une fois pour le stockage de données est ajouté dans cette liste. Grâce à cette liste, il sera possible de faire une opération de « commit » sur tous les connecteurs utilisés en une seule opération juste avant de quitter process_message_queue().
•.Ligne 57 : récupération d’un logger « pmq » spécifiquement destiné à la méthode process_message_queue() (cf. Logger Python dans ce chapitre). Ce logger permet de journaliser les messages spécifiques de cette section du code avec une configuration adaptée.
•.Ligne 58 : poursuivre le traitement des messages jusqu’au moment où la queue est vide.
•.Ligne 59 : obtention d’un message stocké dans la queue FIFO (une instance de QueuedMessage).
•.Ligne 61 : utilisation du logger « pmq » pour tenir une trace des messages traités et la configuration du traitement. Ce message de débogage peut être utile pour vérifier la bonne application des règles de traitement après une modification du fichier de configuration.
•.Ligne 69 : récupération du connecteur à utiliser pour stocker les données. Pour rappel, le connecteur (ex. : SqliteConnector) offre les services de stockage nécessaires pour écrire l’information sur un média (ex. : une base de données SQLite).
Accéder au connecteur depuis le message empilé (réalisé avec Draw.io)
•.Lignes 70 et 71 : enregistrement du connecteur dans la con_list s’il n’y est pas déjà présent. Un même connecteur peut être utilisé plusieurs fois durant le traitement de la queue de messages, ce qui est le cas si plusieurs de ces messages à sauver utilisent la même définition du connecteur.
•.Ligne 72 : utilisation de la classe de capture de souscription (dérivé de MqttBaseCapture) pour ordonner l’opération de stockage. Pour rappel, la classe de capture sait comment manipuler le connecteur pour réaliser le stockage des informations du message.
•.Lignes 74 à 85 : capture des exceptions et logging des informations pertinentes dans le logger « pmq ».
•.Ligne 86 : début du bloc finally qui est exécuté en toutes circonstances (qu’il y ait une exception ou pas).
•.Ligne 87 : pour chaque get() exécuté sur la queue de messages, un appel à task_done() doit être fait pour signaler que la tâche de traitement (sur l’élément extrait de la queue) est terminée.
•.Lignes 89 à 91 : passe en revue tous les connecteurs utilisés et effectue une opération de commit() suivie d’une opération de déconnexion disconnect(). De la sorte, les opérations en écriture sur le support physique sont regroupées pour minimiser le temps de blocage. Cela améliore les accès concurrents en minimisant les accès sporadiques en écriture, particulièrement coûteux sur un support de stockage tel qu’une carte SD.
App
La classe App gère les différents éléments de l’application et leur mise en œuvre.
Par exemple :
•.chargement du fichier de configuration,
•.mise en place du logging,
•.instanciation des objets nécessaires : les classes de captures MQTT dérivées de MqttBaseCapture, les connecteurs dérivés de BaseConnector, la queue FIFO pour recevoir les QueuedMessage, le thread MessageLazyWriter.
Le code de la classe App se présente comme suit :
01: class App:
02: def __init__(self):
03: self.logger = logging.getLogger(’root’)
04: self.logger.info( ’Initializing app’)
05:
06: self.mqtt = None
07: self.message_queue = None
08: self.connectors = None
09: self.sub_handlers = None
10: # Event utilisé pour arrêter les Thread
11: self.stopper = None
12:
13: self.config = Config( INIFILE )
14: self.build_connectors()
15: self.build_sub_handlers()
16:
17: def build_connectors( self ):
18: self.connectors = {} # Réinitialiser le dict.
19: # collecter la liste des sections "connector.xxxx"
20: lst = self.config.search_section( "^connector\.\w+$" )
21: for section in lst:
22: connector_classname = self.config.get(
23: section, ’class’)
24: # reference vers la classe
25: connector_class = globals()[connector_classname]
26: # créer la classe et l’enregistrer
27: # sous son nom simple
28: self.connectors[
29: section.replace(’connector.’,’’)
30: ] = \
31: connector_class( self.config.sections[section] )
32:
33: def build_sub_handlers( self ):
34: self.sub_handlers = [] # Réinitialiser la liste
35: # collecter la liste des sections "mqtt.capture.x"
36: lst = self.config.search_section(
37: "^mqtt\.capture\.\d+$" )
38: for section in lst:
39: handler_classname = self.config.get( section, ’class’
40: )
41: # reference vers la classe
42: handler_class = globals()[handler_classname]
43: # Identifier l’instance du connecteur
44: connector_name = self.config.get(
45: section, ’storage’).split(’.’)[0]
46: if not(connector_name in self.connectors):
47: raise Exception(
48: ’No [connector.%s] defined for storage=%s (see
49: [%s])’%
50: (connector_name,
51: self.config.get( section, ’storage’),
52: section)
53: )
54: connector = self.connectors[connector_name]
55: self.sub_handlers.append(
56: # créer une instance de la classe
57: handler_class(
58: self.config.get( section, ’subscribe’ ),
59: self.config.get( section, ’storage’),
60: connector
61: )
62: )
63:
64: def _mqtt_on_connect( self, client, userdata, flags, rc ):
65: self.logger.info( "mqtt connect return code: %s" % rc )
66: self.mqtt_connected = (rc == 0)
67:
68: def _mqtt_on_message( self, client, userdata, message ):
69: self.logger.info( "getting MQTT message..." )
70: self.logger.info( " topic : %s" % message.topic )
71: self.logger.info( " message: %s" % message.payload )
72: self.logger.info( " QoS : %s" % message.qos )
73: try:
74: to_call = {} # sub handler to call
75: for sub_handler in self.sub_handlers:
76: if sub_handler.match_subscription( message.topic ):
77: # Identifier la destination de sauvegarde
78: # pour éviter de sauvegarder plusieurs
79: # fois le message dans la même table
80: #
81: target_id = sub_handler.target_id()
82: if not( target_id in to_call ):
83: to_call[target_id] = sub_handler
84:
85: for target_id, sub_handler in to_call.items():
86: to_queue = QueuedMessage(
87: receive_time=datetime.datetime.now(), \
88: topic=message.topic, payload=message.payload, \
89: qos=message.qos, sub_handler=sub_handler )
90: self.message_queue.put( to_queue )
91:
92: except Exception as err:
93: self.logger.error(
94: ’Exception while processing MQTT message’)
95: self.logger.error( " topic: %s" % message.topic )
96: self.logger.error( " message: %s" % message.payload )
97: self.logger.error( " exception: %s" % err )
98:
99: def connect_broker( self ):
100: if self.mqtt:
101: del( self.mqtt )
102: self.mqtt = None
103: self.mqtt_connected = False
104:
105: self.mqtt = mqtt_client.Client(
106: client_id = ’push-to-db’ )
107: self.mqtt.on_connect = self._mqtt_on_connect
108: self.mqtt.on_message = self._mqtt_on_message
109: if not( self.config.get(
110: ’mqtt.broker’, ’username’, default = None
111: ) in (None, ’None’) ):
112: self.mqtt.username_pw_set(
113: username = self.config.get( ’mqtt.broker’,
114: ’username’),
115: password = self.config.get( ’mqtt.broker’,
116: ’password’)
117: )
118: self.mqtt.connect(
119: host = self.config.get( ’mqtt.broker’,
120: ’mqtt_broker’ ),
121: port = self.config.getint( ’mqtt.broker’,
122: ’mqtt_port’ ),
123: keepalive = self.config.getint( ’mqtt.broker’,
124: ’mqtt_keepalive’)
125: )
126:
127: # effectue toutes les souscriptions nécessaires
128: sub_done = []
129: for sub_handler in self.sub_handlers:
130: for sub in sub_handler.sub_filters:
131: # Ne pas faire deux fois la même souscription
132: if not sub in sub_done:
133: self.logger.info( ’subscribing %s’ % sub )
134: self.mqtt.subscribe( sub )
135: sub_done.append( sub )
136:
137: def run( self ):
138: self.logger.info( ’Running app’)
139: self.message_queue = Queue()
140: self.stopper = threading.Event()
141:
142: # Thread de traitement des QueuedMessage
143: lazyWriter = MessageLazyWriter(
144: self.config.sections[’lazywriter’], \
145: self.message_queue, self.connectors, \
146: self.stopper )
147: lazyWriter.start()
148:
149: try:
150: self.connect_broker()
151: except Exception as err:
152: self.logger.error(
153: ’connect_broker() error with %s’ % err)
154: raise
155:
156: try:
157: self.mqtt.loop_forever()
158: except Exception as err:
159: self.logger.error(
160: ’Error while processing broker messages! %s’ % err )
161: except KeyboardInterrupt:
162: self.logger.info(
163: ’User abord with KeyboardInterrupt exception’ )
164: except SystemExit:
165: self.logger.info(
166: ’System exit with SystemExit exception!’ )
167:
168: self.stopper.set()
169:
170: self.logger.info( ’Waiting for LazyWriter thread...’)
171: lazyWriter.join()
Un dernier petit rappel sur la structure du fichier de configuration push-to-db.ini pour détailler la définition des classes de captures et des connecteurs. Ces informations seront utilisées pour instancier les objets adéquats à la volée.
[connector.sqlitedb]
class=SqliteConnector
db=/var/local/sqlite/pythonic.db
[mqtt.capture.0]
subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#
class=MqttTopicCapture
storage=sqlitedb.topicmsg
[mqtt.capture.1]
subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/
exterieur/jardin/temp
class=MqttTimeserieCapture
storage=sqlitedb.ts_cab
La classe App expose les propriétés et méthodes suivantes :
•.mqtt : cette propriété maintient une référence vers le client MQTT utilisé pour effectuer les souscriptions sur le broker.
•.message_queue : cette propriété maintient une référence vers la queue FIFO utilisée pour empiler les instances de QueuedMessage reçues par le client MQTT (sur le thread principal) et enlever ces mêmes objets QueuedMessage depuis le thread MessageLazyWriter.
•.connectors : cette propriété maintient un dictionnaire associant le nom du connecteur et l’instance du connecteur. Par exemple : la section [connector.sqlitedb] du fichier de configuration produira une entrée { ’sqlitedb’: <SqlLiteConnector> } dans le dictionnaire. Ce dictionnaire est initialisé par la méthode build_connectors().
•.sub_handlers : cette propriété maintient une liste d’objets dérivés de la classe MqttBaseCapture. Chacun de ces objets maintient une association entre les souscriptions et le traitement à effectuer pour le message correspondant. Il y a un sub_handler par section [mqtt.capture.x] dans le fichier de configuration. Par exemple : la section [mqtt.capture.1] du fichier de configuration produira une entrée [ <MqttTimeserieCapture> ] dans la liste. Ce dictionnaire est initialisé par la méthode build_sub_handlers().
•.config : cette propriété offre un accès au contenu du fichier de configuration par l’intermédiaire d’un objet Config. Ce dernier permet d’accéder aux différentes sections du fichier (ex. : [mqtt.capture.2]) et aux paramètres de ces sections (ex. : storage=sqlitedb.ts_salon). Le fichier de configuration et la classe Config sont abordés plus loin dans ce chapitre.
•.logger : cette propriété référence le logger principal de l’application. Le logger est initialisé au démarrage du script en utilisant les informations disponibles dans le fichier de configuration push-to-db.ini.
•.stopper : cette propriété est destinée à recevoir une instance de threading.Event. Cette instance est partagée avec le thread MessageLazyWriter au moment de sa création. L’événement stopper permet au thread principal de signaler une demande d’arrêt ( self.stopper.set() ). Cette demande peut ensuite être consultée par thread MessageLazyWriter avec stopper.is_set() pour être ainsi informé de l’événement.
•.__init__₍ self ) : initialise la classe applicative. Charge le fichier de configuration et construit le dictionnaire des connecteurs (propriété connectors) et la liste des objets de capture MQTT (propriété sub_handlers).
•.build_connectors( self ) : cette méthode récupère la liste des sections [connector.x] du fichier de configuration et instancie les objets dérivés de BaseConnector. Cette méthode fait l’objet d’une explication détaillée plus avant dans ce chapitre.
•.build_sub_handlers( self ) : cette méthode récupère la liste des sections [mqtt.capture.x] du fichier de configuration puis instancie les différents objets dérivés de MqttBaseCapture. Ces instances sont stockées dans la liste sub_hanlders de la classe App. Les classes de capture faisant référence à des connecteurs, la méthode build_sub_handlers() doit impérativement être appelée après build_connectors().
•._mqtt_on_connect( self, client, userdata, flags, rc ) : méthode de rappel utilisée par le client MQTT lors de la connexion au broker MQTT. Permet de suivre les différentes étapes de connexion grâce à des messages journalisés.
•._mqtt_on_message( self, client, userdata, message ) : méthode de rappel appelée par le client MQTT à la réception de chaque message en provenance du broker pour lequel il y a eu une souscription. Cette méthode aura pour tâche d’identifier le sub_handlers concerné par le message (sur base du topic du message) et d’empiler un QueuedMessage de traitement à réaliser dans la queue message_queue. À noter que plusieurs traitements sub_handlers peuvent s’appliquer à la réception d’un même message MQTT.
•.connect_broker( self ) : cette méthode instancie (ou réinstancie) le client MQTT et effectue la connexion sur le broker avec les paramètres mentionnés à la section [mqtt.broker] du fichier de configuration. Cette méthode assigne également les fonctions de rappel et effectue les différentes souscriptions sur le broker (souscriptions mentionnées dans les sections [mqtt.capture.x]).
•.run( self ) : cette méthode exécute la partie applicative. Elle met en place la message_queue, l’événement stopper, le thread MessageLazyWriter, la connexion au broker puis elle démarre la boucle de traitement des messages MQTT. À noter que toute exception produite durant le traitement des messages MQTT produit l’arrêt du thread MessageLazyWriter (signalisation via le stopper).
Méthode build_connectors( self )
Cette méthode de App constitue le dictionnaire connectors en instanciant les différentes classes dérivées de BaseConnector mentionnées dans les sections [connector.x] du fichier de configuration.
Voici la section de code correspondant à build_connectors() :
17: def build_connectors( self ):
18: self.connectors = {} # Réinitialiser le dict.
19: # collecter la liste des sections "connector.xxxx"
20: lst = self.config.search_section( "^connector\.\w+$" )
21: for section in lst:
22: connector_classname = self.config.get(
23: section, ’class’)
24: # reference vers la classe
25: connector_class = globals()[connector_classname]
26: # créer la classe et l’enregistrer
27: # sous son nom simple
28: self.connectors[
29: section.replace(’connector.’,’’)
30: ] = \
31: connector_class( self.config.sections[section] )
•.Ligne 18 : réinitialise le contenu du dictionnaire connectors.
•.Ligne 20 : utilisation de la méthode search_section de l’objet de configuration pour obtenir une liste des noms de section correspondant à l’expression régulière ^connector\.\w+$ (donc commençant par la chaîne « connectors. » suivie d’une ou plusieurs lettres jusqu’à la fin de la chaîne de caractères). Tel que configuré, lst doit contenir la liste [’connector.sqlitedb’].
•.Ligne 21 : pour chacun des éléments de la liste (donc chacune des sections « connector. » concernées).
•.Ligne 22 : utilisation de l’objet config pour récupération du nom de la classe, correspondant à la valeur de la clé « class » dans la section section du fichier de configuration. Pour la clé section égale à connector.sqlitedb, la valeur attendue pour la clé class est SqliteConnector (stockée dans la variable connector_classname).
•.Ligne 25 : globals() retourne un dictionnaire avec toutes les définitions globales, ceci y compris les classes définies globalement (ex. : {’d’: {’hello’: 12}, ’TestClass’: <class ’__main__.TestClass’>, ’__name__’: ’__main__’} ). Par conséquent, globals()[’SqliteConnector’] retourne une référence vers la classe SqliteConnector, référence de classe stockée dans la variable connector_class.
•.Lignes 28 à 30 : ajout d’une référence (voir ligne 30) dans le dictionnaire connectors en utilisant une partie du nom de section (ce qu’il y a derrière le point). Dans le cas de la section [connector.sqlitedb], la clé sera donc « sqlitedb ».
•.Ligne 31 : création d’une instance de la classe référencée par la variable connector_class. Si connector_class référence la classe SqliteConnector alors l’exécution de connector_class() est équivalente à l’exécution de SqliteConnector(), ce qui crée une instance de la classe. Toutes les classes de connecteurs dérivant de BaseConnector, la création de l’instance doit recevoir un dictionnaire des paramètres contenus dans la section [connector.x] du fichier de configuration. La méthode sections[ nom_de_section ] de l’objet config permet justement de récupérer toutes les valeurs clé=valeur de la section nom_de_section.
Méthode build_sub_handlers(self)
Cette méthode constitue la liste sub_handler en instanciant les différentes classes issues de MqttBaseCapture mentionnées dans les différentes sections [mqtt.capture.x] du fichier de configuration.
Voici la section de code correspondant à build_sub_handlers() :
33: def build_sub_handlers( self ):
34: self.sub_handlers = [] # Réinitialiser la liste
35: # collecter la liste des sections "mqtt.capture.x"
36: lst = self.config.search_section(
37: "^mqtt\.capture\.\d+$" )
38: for section in lst:
39: handler_classname = self.config.get( section, ’class’
40: )
41: # reference vers la classe
42: handler_class = globals()[handler_classname]
43: # Identifier instance du connecteur
44: connector_name = self.config.get(
45: section, ’storage’).split(’.’)[0]
46: if not(connector_name in self.connectors):
47: raise Exception(
48: ’No [connector.%s] defined for storage=%s (see
49: [%s])’%
50: (connector_name,
51: self.config.get( section, ’storage’),
52: section)
53: )
54: connector = self.connectors[connector_name]
55: self.sub_handlers.append(
56: # créer une instance de la classe
57: handler_class(
58: self.config.get( section, ’subscribe’ ),
59: self.config.get( section, ’storage’),
60: connector
61: )
62: )
La méthode build_sub_handlers() est très similaire à build_connectors() sauf pour les exceptions suivantes :
1. | Les sections lues dans le fichier de configuration sont [mqtt.capture.x] et non [connector.x]. |
2. | Les classes instanciées sont des descendants de MqttBaseCapture et non des descendants de BaseConnector. |
Sont reprises ci-dessous les lignes qui présentent de réelles différences par rapport à build_connectors(). La section [mqtt.capture.0] est utilisée comme référence.
•.Ligne 42 : récupération d’une référence vers une classe dérivée de MqttBaseCapture, accessible par la handler_class. Procédé expliqué en détail pour la méthode build_connectors().
•.Ligne 44 : identification du connecteur à utiliser pour le stockage de données. Cette information provient de la donnée stockée pour la clé « storage » (soit la valeur sqlitedb.topicmsg). Seule la première partie est utilisée pour indiquer le connecteur à utiliser (soit la valeur sqlitedb). Cette valeur est récupérée à l’aide de la méthode split() pour être stockée dans la variable connector_name. En effet, l’instruction Python ’sqlitedb.topicmsg’.split(’.’)[0] retourne la valeur ’sqlitedb’.
•.Lignes 46 à 53 : vérifie si le dictionnaire connectors contient bien une entrée pour la valeur de connector_name et donc une instance de connecteur pouvant être utilisée. Si ce n’est pas le cas, alors le fichier de configuration est erroné et le script lève une exception (ce qui provoque l’arrêt du script).
•.Ligne 54 : récupération de la référence vers l’objet connecteur (dérivé de BaseConnector).
•.Ligne 55 : ajout d’un nouvel objet dans la liste sub_handlers. Cette instance est créée dans les lignes 57 à 61.
•.Lignes 57 à 61 : création d’une instance de la classe référencée par handler_class. La classe référencée étant dérivée de MqttBaseCapture, l’instance est créée en passant les paramètres nécessaires, à savoir :
•.les souscriptions,
•.l’identification de la ressource de stockage, donc le nom d’une table pour un connecteur SqliteConnector,
•.une instance du connecteur (dérivé de BaseConnector) permettant d’effectuer l’opération de stockage.
Méthode _mqtt_on_message( self, client, userdata, message )
Cette méthode est appelée par le client MQTT à la réception de chaque message transmis par le broker (suite aux différentes souscriptions). Cette méthode identifie les sub_handlers concernés par le message et effectue la mise en queue pour le futur traitement à réaliser par MessageLazyWriter.
Voici la section de code correspondant à _mqtt_on_message() :
68: def _mqtt_on_message( self, client, userdata, message ):
69: self.logger.info( "getting MQTT message..." )
70: self.logger.info( " topic : %s" % message.topic )
71: self.logger.info( " message: %s" % message.payload )
72: self.logger.info( " QoS : %s" % message.qos )
73: try:
74: to_call = {} # sub handler à appeler
75: for sub_handler in self.sub_handlers:
76: if sub_handler.match_subscription( message.topic ):
77: # Identifier la destination de sauvegarde pour
78: # eviter
79: # de sauvegarder plusieurs fois le message dans la
80: # même table
81: target_id = sub_handler.target_id()
82: if not( target_id in to_call ):
83: to_call[target_id] = sub_handler
84:
85: for target_id, sub_handler in to_call.items():
86: to_queue = QueuedMessage(
87: receive_time=datetime.datetime.now(), \
88: topic=message.topic, payload=message.payload, \
89: qos=message.qos, sub_handler=sub_handler )
90: self.message_queue.put( to_queue )
91:
92: except Exception as err:
93: self.logger.error(
94: ’Exception while processing MQTT message’)
95: self.logger.error( " topic: %s" % message.topic )
96: self.logger.error( " message: %s" % message.payload )
97: self.logger.error( " exception: %s" % err )
•.Lignes 69 à 72 : envoi du message dans le logger pour analyse et débogage. En fonction de la configuration du logger dans le fichier de configuration (cf. Logger Python dans ce chapitre) cette information est affichée à l’écran, enregistrée dans un fichier journal ou simplement ignorée.
•.Ligne 74 : définition d’un dictionnaire (clé = identifiant unique du sub_handler) pour enregistrer les différents objets sub_handler à appeler. Ceci permet d’éviter qu’un même message produise deux enregistrements identiques dans la même destination. Un tel cas de figure pourrait se produire avec les souscriptions maison/+/chambre1/# et maison/+/+/temp qui captureraient deux fois le message maison/etage/chambre1/temp, il faut donc veillez à ne réaliser l’opération de traitement (stockage) qu’une seule fois malgré la double couverture.
•.Lignes 75 à 76 : passer tous les sub_handlers en revue pour voir si le message correspond aux souscriptions du sub_handler. Si c’est le cas, les lignes 81 à 83 sont exécutées.
•.Ligne 81 : récupérer l’identifiant unique du sub_handler.
•.Lignes 82 à 83 : si le sub_handler n’est pas encore enregistré dans la liste to_call, c’est qu’il faudra exécuter ce sub_handler pour le message. Si le sub_handler est déjà enregistré (par exemple à cause d’une autre section [mqtt.capture.x] qui capture le même message avec un autre filtre de souscription, mais utilisant le même sub_handler pour le stockage), alors l’entrée existe déjà dans le dictionnaire et il n’est pas nécessaire de l’ajouter une seconde fois.
•.Lignes 85 à 90 : pour chacun des sub_handler enregistrés dans la liste to_call, créer un QueuedMessage associant message et sub_handler à appeler puis l’ajouter dans la queue de traitement message_queue.
•.Ligne 86 : création d’une instance QueuedMessage avec toutes les informations utiles du message (heure, topic, payload, qualité de service) ainsi qu’une référence vers le sub_handler à exécuter (pour faciliter le travail du LazyMessageWriter).
•.Ligne 90 : empiler le message dans la queue de traitement message_queue.
•.Lignes 92 à 97 : capture de toutes les exceptions et enregistrement d’un message d’erreur dans le fichier journal. La capture de l’exception permet au script de continuer son traitement malgré l’exception.
Méthode connect_broker( self )
Cette méthode connecte (ou reconnecte) l’application au broker MQTT à l’aide d’un client MQTT. La méthode effectue également toutes les souscriptions mentionnées dans les différentes sections [mqtt.capture.x].
Voici la section de code correspondant à connect_broker() :
99: def connect_broker( self ):
100: if self.mqtt:
101: del( self.mqtt )
102: self.mqtt = None
103: self.mqtt_connected = False
104:
105: self.mqtt = mqtt_client.Client(
106: client_id = ’push-to-db’ )
107: self.mqtt.on_connect = self._mqtt_on_connect
108: self.mqtt.on_message = self._mqtt_on_message
109: if not( self.config.get(
110: ’mqtt.broker’, ’username’, default = None
111: ) in (None, ’None’) ):
112: self.mqtt.username_pw_set(
113: username = self.config.get( ’mqtt.broker’,
114: ’username’),
115: password = self.config.get( ’mqtt.broker’,
116: ’password’)
117: )
118: self.mqtt.connect(
119: host = self.config.get( ’mqtt.broker’,
120: ’mqtt_broker’ ),
121: port = self.config.getint( ’mqtt.broker’,
122: ’mqtt_port’ ),
123: keepalive = self.config.getint( ’mqtt.broker’,
124: ’mqtt_keepalive’)
125: )
126:
127: # effectue toutes les souscriptions nécessaires
128: sub_done = []
129: for sub_handler in self.sub_handlers:
130: for sub in sub_handler.sub_filters:
131: # Ne pas faire deux fois la même souscription
132: if not sub in sub_done:
133: self.logger.info( ’subscribing %s’ % sub )
134: self.mqtt.subscribe( sub )
135: sub_done.append( sub )
•.Lignes 100 à 103 : déconnexion et destruction du client MQTT si celui-ci est déjà assigné.
•.Ligne 105 : création d’un client MQTT avec identification du client (cf. paramètre client_id).
•.Lignes 107 à 108 : assignation des fonctions de rappel MQTT.
•.Lignes 109 à 117 : assignation du login et du mot de passe MQTT si ceux-ci sont définis dans le fichier de configuration (voir clés username et password de la section [mqtt.broker] ).
•.Ligne 128 : création d’une liste sub_done pour mémoriser toutes les souscriptions réalisées sur le broker. Cette liste est utilisée pour éviter les souscriptions en double sur le broker.
•.Lignes 129 à 130 : pour chacune des expressions de filtrage (les souscriptions, disponibles dans sub_filters) enregistrées dans chacun des sub_handler.
•.Lignes 132 à 135 : vérifier dans sub_done si la souscription n’a pas déjà été faite sur le broker MQTT sinon réaliser la souscription (ligne 134) et enregistrer la souscription dans sub_done (ligne 135).
Méthode run( self )
Cette méthode prend en charge l’exécution de la partie applicative du script push-to-db.py.
Voici la section de code correspondant à run() :
137: def run( self ):
138: self.logger.info( ’Running app’)
139: self.message_queue = Queue()
140: self.stopper = threading.Event()
141:
142: # Thread de traitement des QueuedMessage
143: lazyWriter = MessageLazyWriter(
144: self.config.sections[’lazywriter’],
145: self.message_queue, self.connectors,
146: self.stopper )
147: lazyWriter.start()
148:
149: try:
150: self.connect_broker()
151: except Exception as err:
152: self.logger.error(
153: ’connect_broker() error with %s’ % err)
154: raise
155:
156: try:
157: self.mqtt.loop_forever()
158: except Exception as err:
159: self.logger.error(
160: ’Error while processing broker messages! %s’ % err )
161: except KeyboardInterrupt:
162: self.logger.info(
163: ’User abord with KeyboardInterrupt exception’ )
164: except SystemExit:
165: self.logger.info(
166: ’System exit with SystemExit exception!’ )
167:
168: self.stopper.set()
169:
170: self.logger.info( ’Waiting for LazyWriter thread...’)
171: lazyWriter.join()
•.Ligne 139 : création de la queue FIFO message_queue partagée avec le thread MessageLazyWriter.
•.Ligne 140 : création de l’événement stopper permettant de signaler au thread MessageLazyWriter qu’il doit s’arrêter.
•.Ligne 143 : création du thread MessageLazyWriter. Les paramètres communiqués sont :
•.un dictionnaire avec toutes les entrées de la section [lazywriter] du fichier de configuration,
•.la queue FIFO contenant les messages à traiter,
•.la liste des connecteurs disponibles,
•.l’événement stopper.
•.Ligne 147 : démarrage du thread.
•.Ligne 150 : connexion au broker MQTT et souscription aux différents topics.
•.Lignes 151 à 154 : en cas d’erreur durant la phase de connexion au broker, l’exception est enregistrée dans le fichier de log et relevée (pour arrêter l’exécution du script).
•.Ligne 157 : exécution de la boucle de traitement des messages MQTT. Sauf exception, cette boucle est exécutée indéfiniment.
•.Lignes 158 à 166 : capture de différents cas d’exception. Les exceptions sont capturées afin de permettre l’exécution des lignes 168 et suivantes.
•.La capture de l’exception KeyboardInterrupt qui est produite lorsque le script est arrêté avec la combinaison de touches [Ctrl] C, pratique lorsqu’il est nécessaire d’arrêter le script exécuté en ligne de commande.
•.La capture de l’exception SystemExit qui est produite lorsque le script est arrêté par le gestionnaire de service (systemd).
•.Ligne 168 : à ce point du script, la boucle de traitement des messages MQTT a été interrompue par une exception. L’instruction self.stopper.set() active le drapeau de l’événement signalant au thread LazyMessageWriter qu’il doit terminer son exécution.
•.Ligne 171 : attendre la fin d’exécution du thread.
La classe App et sa méthode run() sont utilisées en fin de script push-to-db.py pour démarrer celui-ci.
if __name__ == "__main__":
app = App()
app.run()
Le script push-to-db.py utilise un fichier de configuration, un fichier de base de données et un fichier de log. Ces éléments sont stockés en suivant les recommandations FHS (Filesystem Hierarchy Standard) pour les distributions Linux et les autres documentations annexes concernant le système de fichier Linux.
Un script d’installation setup.sh est disponible dans le sous-répertoire /push-to-db/ du dépôt GitHub du projet. Ce script Shell, détaillé plus loin dans cette section, prend soin de configurer les accès, de placer les différents éléments au bon endroit, de créer la base de données, etc.
Script d’installation pour push-to-db.py
Le système de fichiers Linux
Une abondante documentation existe sur le sujet. Les points ci-dessous représentent une source d’information de choix.
•.le livre « Linux. Principes de base de l’utilisation du système » de Nicolas Pons (Éditions ENI) - chapitre Arborescence Linux, une très bonne introduction sur le sujet
•.https://en.wikipedia.org/wiki/Filesystem_Hierarchy_Standard
•.https://www.tldp.org/LDP/sag/html/var-fs.html (The Linux Documentation Project)
•.http://www.pathname.com/fhs/ (documentation détaillée sur les systèmes de fichiers)
Fichier de configuration
Le répertoire /etc contient les informations de configuration spécifiques au système. Le fichier de configuration push-to-db.ini est stocké dans le répertoire /etc/pythonic/.
Il est nécessaire d’être super utilisateur pour éditer le contenu du fichier avec la commande :
sudo nano /etc/pythonic/push-to-db.ini
Base de données
Le répertoire /var/local/sqlite est destiné aux bases de données SQLite, y compris pythonic.db qui stocke les messages réceptionnés par le broker.
Le répertoire /var contient des fichiers dont le contenu est susceptible de changer constamment. Le répertoire /var/local/ est utilisé pour stocker des données variables spécifiques au système de fichiers local. À moins de configurer les droits de façon appropriée, seul le super utilisateur peut altérer le contenu de ce répertoire.
Fichier journal
Le script utilise un logger Python configuré dans le fichier de configuration push-to-db.ini. Le fichier journal push-to-db.log est, par défaut, placé dans /var/log/pythonic/.
L’accès en écriture à ce répertoire en tant qu’utilisateur pi nécessite une configuration adéquate des droits d’accès.
Le script push-to-db
Dans une installation définitive, le script push-to-db.py trouverait sa place dans le répertoire /usr/bin.
Étant donné que cet élément est activement en cours de développement au moment de la rédaction de ces lignes, son emplacement est préservé dans le répertoire utilisateur du Raspberry Pi ( donc sous /home/pi/ ) où il est tout aussi facile de l’exécuter.
Le script push-to-db.py est accessible dans l’un des sous-répertoires de la-maison-pythonic résultant du clonage du projet GitHub. Par exemple :
/home/pi/la-maison-pythonic/python/push-to-db/push-to-db.py
Les tables sont créées à l’aide du script SQL createdb.sql détaillé ci-dessous, script également disponible sur le dépôt GitHub du projet (la-maison-pythonic/python/push-to-db/createdb.sql).
create table topicmsg (
id integer primary key,
topic text,
message text,
qos integer,
rectime integer,
tsname text
);
CREATE UNIQUE INDEX idx_topicmsg ON topicmsg(topic);
create table ts_cab (
id integer primary key,
topic text,
message text,
qos integer,
rectime integer
);
create table ts_salon (
id integer primary key,
topic text,
message text,
qos integer,
rectime integer
);
create table ts_chauf (
id integer primary key,
topic text,
message text,
qos integer,
rectime integer
);
L’utilitaire sqlite3 est utilisé pour créer la base de données. Si SQLite 3 n’est pas encore disponible, il peut être installé avec la commande sudo apt-get install sqlite.
Par la suite, la base de données peut être créée à l’aide de l’instruction :
pi@pythonic:~ $ cat createdb.sql | sqlite3 pythonic.db
Avant d’être déplacée dans le répertoire /var/local/sqlite.
À noter que la création et l’accès à la base de données dans le répertoire /var/local/sqlite nécessite une configuration de droit particulier. Ce point est détaillé dans le script d’installation setup.sh (ci-dessous).
Le fichier de configuration push-to-db.ini contient toutes les informations nécessaires au fonctionnement du script push-to-db.py.
01: [mqtt.broker]
02: mqtt_broker=pythonic.local
03: mqtt_port=1883
04: mqtt_keepalive=45
05: username=pusr103
06: password=21052017
07:
08: [lazywriter]
09: MaxQueueLatency=120
10: MaxQueueSize=10
11: PauseAfterProcess=2
12:
13: [connector.sqlitedb]
14: class=SqliteConnector
15: db=/var/local/sqlite/pythonic.db
16:
17: [mqtt.capture.0]
18: subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#
19: class=MqttTopicCapture
20: storage=sqlitedb.topicmsg
21:
22: [mqtt.capture.1]
23: subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardi
24: n/hrel,maison/exterieur/jardin/temp
25: class=MqttTimeserieCapture
26: storage=sqlitedb.ts_cab
27:
28: [mqtt.capture.2]
29: subscribe=maison/rez/salon/temp,maison/rez/salon/pir
30: class=MqttTimeserieCapture
31: storage=sqlitedb.ts_salon
32:
33: [mqtt.capture.3]
34: subscribe=maison/cave/chaufferie/etat,maison/cave/chaufferie
35: /temp-eau
36: class=MqttTimeserieCapture
37: storage=sqlitedb.ts_chauf
38:
39: [loggers]
40: keys=root,connector,pmq
41:
42: [handlers]
43: keys=console,logfile
44:
45: [formatters]
46: keys=default
47:
48:
49: [logger_root]
50: level=NOTSET
51: handlers=console,logfile
52:
53: [logger_connector]
54: level=ERROR
55: handlers=console,logfile
56: qualname=connector
57:
58: [logger_pmq]
59: level=ERROR
60: handlers=console,logfile
61: qualname=pmq
62:
63: [handler_console]
64: class=StreamHandler
65: level=NOTSET
66: formatter=default
67: args=(sys.stdout,)
68:
69: [handler_logfile]
70: class=FileHandler
71: level=NOTSET
72: formatter=default
73: args=(’/var/log/pythonic/push-to-db.log’, ’w’)
74:
75: [formatter_default]
76: format=%(asctime)s %(levelname)s %(message)s
77: datefmt=
La section mqtt.broker
Cette section (lignes 01 à 06) contient les informations permettant de connecter le client MQTT sur le broker.
•.mqtt_broker : broker à contacter. Cette clé peut contenir une adresse IP, un identifiant sur le réseau local (ex. : pythonic.local) ou une URI (ex. : http://myMqtt.mon_domaine.com)
•.mqtt_port : port à utiliser pour contacter le broker MQTT. La valeur par défaut est 1883.
•.mqtt_keepalive : valeur en secondes. C’est le temps maximum durant lequel le client et le broker MQTT peuvent rester sans communication (cette valeur est communiquée par le client au moment de la connexion). Le client est responsable de maintenir une communication régulière, s’il n’a pas de donnée à transmettre au terme des x secondes un échange de paquet PINGREQ intervient pour maintenir la connexion active.
La section lazywriter
Cette section (lignes 08 à 11) contient les informations permettant de configurer le comportement du thread MessageLazyWriter.
•.MaxQueueLatency : temps en secondes. Temps de latence maximum pour l’écriture du premier message reçu dans la queue de traitement. Il vaut 120 secondes par défaut.
•.MaxQueueSize : taille maximale de la queue de traitement provoquant immédiatement une opération d’écriture. 10 messages par défaut.
•.PauseAfterProcess : temps de pause (en secondes) après une opération d’écriture. Il vaut 2 secondes par défaut.
La section connector.x
Les sections connecteurs permettent de déclarer les connecteurs (via une classe à instancier) et les paramètres de configuration à passer à ladite instance.
Cela permet de connecter le script push-to-db.py sur plusieurs destinations distinctes (plusieurs bases de données SQLite ou tout autre connecteur disponible dans l’application) pour y sauver des messages.
Le texte situé après le point dans le nom de section (ex. : sqlitedb pour la section [connector.sqlitedb] ) devient un identifiant réutilisé plus loin dans la configuration.
•.class : classe de l’objet qui doit être instancié dans le script. Classe dérivée de BaseConnector comme par exemple SqliteConnector.
•.autres paramètres : une section connector.x peut avoir de nombreux paramètres clé=valeur. Ceux-ci sont tous passés à la classe lors de la création de l’instance (sous forme de dictionnaire). Les paramètres à mentionner dépendent de la classe à créer.
Paramètre | Description |
Classe = SqliteConnector | |
db | Requis. Chemin complet permettant d’accéder à une base de données Sqlite3. Par exemple : /var/local/sqlite/pythonic.db. |
Les sections de capture permettent de définir les messages à capturer et le connecteur à utiliser pour stocker les messages capturés.
Les sections mqtt.capture.x définissent également la classe de capture à utiliser (la classe dérivée de MqttBaseCapture à instancier). La section contient également les divers paramètres à passer à la classe durant la création de celle-ci.
Le numéro situé après le point dans le nom de section (ex. : 0 pour la section [mqtt.capture.0] ) est un identifiant unique de la section.
•.class : classe de l’objet qui doit être instancié dans le script. Cette classe est dérivée de MqttBaseCapture. Par exemple : MqttTopicCapture.
•.subscribe : souscriptions (séparées par des virgules) à effectuer sur le broker MQTT. Par exemple : maison/rez/#,maison/+/+/temp.
•.storage : constitué de deux éléments séparés par un point : <identifiant_connecteur>.<storage_target>. L’identifiant de connecteur renvoie à une section [connector.<identifiant_connecteur>]. La partie <storage_target> est une information complémentaire communiquée à l’objet de capture, cette information renvoie vers une ressource de type fichier ou table en fonction du connecteur utilisé. Par exemple : storage=sqlitedb.topicmsg renvoie vers le connecteur défini dans la section [connector.sqllitedb] (par conséquent, une base de données Sqlite3 et le fichier correspondant).
Les sections loggers, handlers, formatters, logger_x, handler_x
Toutes ces sections concernent la configuration du logger Python.
Ce point est abordé plus en détail dans la section Logger Python de ce chapitre.
Le script d’installation setup.sh disponible dans le sous-répertoire /push-to-db/ du dépôt GitHub du projet permet de procéder rapidement à l’installation de la base de données et de tous les éléments de la configuration.
Le script prend également en compte quelques éléments de sécurité détaillés dans l’explication ci-dessous.
Ce script peut être exécuté à l’aide des commandes suivantes :
pi@pythonic:~ $ cd la-maison-pythonic/python/push-to-db/
pi@pythonic:~/la-maison-pythonic/python/push-to-db $ ./setup.sh
Voici les détails du script d’installation :
01: #!/bin/sh -
02: echo "Install SQLite3"
03: sudo apt-get install sqlite
04:
05: echo "Create storage path /var/local/sqlite"
06: sudo mkdir /var/local/sqlite
07:
08: echo "Add user ’pi’ to group ’staff’"
09: sudo usermod -a -G staff pi
10: echo "Set ’staff’ as group for ’sqlite’ folder"
11: sudo chgrp staff /var/local/sqlite
12: echo "Give right to group ’staff’ on ’sqlite’ folder"
13: sudo chmod g+rwx /var/local/sqlite
14:
15: echo "Install push-to-db.ini to /etc/pythonic/"
16: sudo mkdir /etc/pythonic
17: sudo cp inifile.sample /etc/pythonic/push-to-db.ini
18:
19: echo "Install push-to-db.log in /var/log/pythonic/"
20: sudo mkdir /var/log/pythonic
21: sudo chgrp staff /var/log/pythonic
22: sudo chmod g+rw /var/log/pythonic
23:
24: touch /var/log/pythonic/push-to-db.log
25: sudo chmod +664 /var/log/pythonic/push-to-db.log
26: sudo chown root /var/log/pythonic/push-to-db.log
27:
28: echo "Creating pythonic.db in var/local/sqlite/"
29: cat createdb.sql | sqlite3 /var/local/sqlite/pythonic.db
30:
31: echo "Installing python libraries"
32: sudo pip install paho-mqtt
33:
34: echo "Done!"
•.Ligne 1 : en-tête shebang indiquant que le fichier est un script ainsi que l’interpréteur de commande à utiliser.
•.Ligne 3 : installation de Sqlite3 s’il n’est pas encore installé.
•.Ligne 6 : création du répertoire pour le stockage de la base de données SQLite. Cette opération est réalisée avec une commande sudo car l’accès au répertoire /var/local n’est pas libre.
Le répertoire /var/local est détenu par l’utilisateur root et accessible aux membres du groupe staff. Ce point peut être vérifié avec la commande ls -l /var/local. Le script fera en sorte de suivre la même règle pour le répertoire /var/local/pythonic tout en permettant à l’utilisateur pi de pouvoir y accéder. Cela permettra au script push-to-db.py, d’être exécuté en ligne de commande et d’accéder librement au fichier de base de données.
•.Ligne 8 : ajouter l’utilisateur pi au groupe staff.
•.Ligne 11 : changer le groupe utilisateur détenant le répertoire /var/local/pythonic vers le groupe staff.
•.Ligne 13 : ajouter les droits en lecture r, écriture w et exécuter x au groupe staff associé à /var/local/sqlite.
•.Ligne 16 : création du répertoire de configuration /etc/pythonic pour le stockage du fichier de configuration. Ce répertoire est créé avec une commande sudo car l’accès n’est pas libre.
•.Ligne 17 : copie du fichier d’initialisation d’exemple (inifile.sample) dans le répertoire /etc/pythonic/ sous le nom push-to-db.ini. L’utilisation de la commande sudo permet de passer outre les restrictions d’accès.
La commande ls -l /etc/pythonic révèle que le fichier ini dispose de la configuration d’accès suivante :
-rw-r--r-- 1 root root 2181 Apr 16 22:07 push-to-db.ini
Ce qui signifie que le fichier ini est accessible en lecture par tous (le propriétaire, le groupe et les autres) mais uniquement modifiable par le propriétaire (un sudo sera donc de rigueur pour adapter le contenu).
•.Ligne 20 : création du répertoire /var/log/pythonic/ destiné à recevoir le fichier journal push-to-db.log. Le répertoire n’étant pas en accès libre, la commande sudo permet de créer le répertoire et le fichier log.
•.Lignes 21 à 22 : assignation du groupe staff et du droit associé comme pour le répertoire /var/log/pythonic/.
•.Ligne 24 : création du fichier push-to-db.log.
•.Ligne 25 : changement des permissions sur le fichier de log à +664, ce qui correspond à -rw-rw-r--.
•.Ligne 26 : changement du propriétaire du fichier vers root.
La commande touch de la ligne 24 crée le fichier sans utiliser sudo. Par conséquent, le propriétaire du fichier est pi et le groupe est également pi pour ce même fichier. Le changement de mode en ligne 25 permet au propriétaire et au groupe d’écrire dans le fichier. Pour finir le changement de propriétaire en root permettra au système de modifier le fichier (ce qui sera le cas sous systemd) tandis que le groupe pi permettra toujours à l’utilisateur pi de modifier également le fichier (ce qui est le cas lors de l’utilisation en ligne de commande).
•.Ligne 29 : création de la base de données /var/local/sqlite/pythonic.db à partir du fichier createdb.sql.
•.Ligne 32 : installation de la bibliothèque MQTT pour Python (paho-mqtt). L’utilitaire pip permet d’installer un paquet pour Python 2.7.
Python dispose d’un module logging offrant de très nombreuses fonctionnalités facilitant les opérations de journalisation.
Le script push-to-db.py utilise un logger Python associé à une configuration présente dans le fichier push-to-db.ini.
La documentation Python 2.7 offre une excellente référence en anglais sous le nom « Logging Cookbook » (https://docs.python.org/2/howto/logging-cookbook.html).
Il est possible d’initialiser un logger dans le script principal depuis un fichier de configuration.
Voici une section de code extraite de push-to-db.py où le fichier ini est utilisé pour configurer le logger. En créant une instance du logger en début de script, cette instance sera active durant tout le temps de fonctionnement du script. L’instance pourra être facilement récupérée à l’aide de la fonction getLogger( nom_du_logger ) du module logging.
import logging, logging.config
INIFILE = "/etc/pythonic/push-to-db.ini"
logger = logging.config.fileConfig( INIFILE )
Le fichier de configuration du logger utilise une structure inifile pour définir cette configuration qui peut donc cohabiter avec les autres paramètres du script push-to-db.py.
Parmi ces paramètres, le plus important est la section définissant les loggers disponibles pour l’application :
[loggers]
keys=root,connector,pmq
Les objets root, connectors et pmq sont donc accessibles depuis le script Python. À noter que les noms utilisables dans le script Python (le qualname) sont précisés plus loin dans la configuration.
Le projet prévoit trois loggers :
•.root : logger principal (racine) destiné à recevoir les messages généraux de l’application. Au minimum, celui-ci doit être défini.
•.connector : ce connecteur reçoit les messages de logging spécifiques aux classes connecteurs (BaseConnector et dérivées).
•.pmq : (process message queue) logging des messages concernant le traitement des messages MQTT présents dans la queue.
La configuration reprend également la définition des handlers. Ces handlers permettent d’envoyer les messages vers un fichier, un log circulaire, la console, un stream, un serveur de journalisation, etc.
La section [handlers] contient une liste des handlers disponibles :
[handlers]
keys=console,logfile
Dans le cas présent, la configuration prévoit deux handlers : console et logfile (fichier journal).
Chaque handler doit ensuite avoir une section [handler_<nom_du_hander>] configurant précisément celui-ci.
Le handler console renvoie tous les messages vers la console à l’aide de la classe StreamHandler.
[handler_console]
class=StreamHandler
level=NOTSET
formatter=default
args=(sys.stdout,)
Le niveau NOTSET indique que tous les messages (DEBUG, INFO, WARNING, ERROR, CRITICAL) seront capturés par ce handler.
Le handler logfile renvoie les messages vers un fichier à l’aide de la classe FileHandler. Comme pour le handler console, tous les messages seront capturés (level=NOTSET).
[handler_logfile]
class=FileHandler
level=NOTSET
formatter=default
args=(’/var/log/pythonic/push-to-db.log’, ’w’)
Les handlers utilisent un formatter pour mettre en forme le message envoyé vers le handler. À noter que les formatters disponibles doivent également être énumérés dans une section [formatters].
[formatters]
keys=default
[formatter_default]
format=%(asctime)s %(levelname)s %(message)s
datefmt=
Vient enfin la configuration des différents loggers dans des sections [logger_<nom_du_logger>]. Il y aura donc trois sections puisqu’il y a trois loggers : root, connector, pmq.
[logger_root]
level=NOTSET
handlers=console,logfile
[logger_connector]
level=ERROR
handlers=console,logfile
qualname=connector
[logger_pmq]
level=ERROR
handlers=console,logfile
qualname=pmq
Ces sections définissent pour chaque logger :
•.level : le niveau de message à capturer parmi DEBUG, INFO, WARNING, ERROR, CRITICAL ou NOTSET. La valeur NOTSET permet de capturer tous les messages. Un niveau ERROR capture les messages ERROR et CRITICAL. Le niveau INFO capture les niveaux INFO, WARNING, ERROR et CRITICAL.
•.qualname : le nom hiérarchique du logger (root par défaut). C’est le nom utilisé par l’application pour obtenir une référence vers le logger.
•.handlers : liste des handlers à utiliser pour enregistrer les messages envoyés au logger. Dans le cas présent, le fichier de log et la sortie vers la console.
Comme précisé ci-dessus, le logger dispose de trois configurations de base qui sont : root, connector, pmq (process message queue).
Ces configurations dans le fichier d’initialisation doivent être lues par le script Python. Cela se fait à l’aide de fileConfig comme indiqué ci-dessous.
import logging, logging.config
INIFILE = "/etc/pythonic/push-to-db.ini"
# A exécuter aussi vite que possible
logger = logging.config.fileConfig( INIFILE )
Par la suite, il est très facile d’obtenir une référence vers un logger en utilisant la fonction getLogger(nom_du_logger) du module logging.
monLog = logging.getLogger(’root’)
La référence vers monLog offre différentes méthodes pour envoyer des messages vers le logger correspondant.
monLog.debug(’ --list of sub_filters’ )
monLog.info( ’LazyWriter queue size reached’ )
monLog.error( ’process_message_queue encounter an error’ )
# voir aussi les méthodes :
# * warning()
# * critical()
# * exception() qui fait un log en debug avec l’info d’exception.
En fonction de la configuration du niveau de logging (level=), ces messages sont ignorés ou pas. La configuration permet également d’envoyer ces messages vers plusieurs destinations (handlers=) comme la console, des fichiers de logs ou des serveurs TCP/IP de log.
Le script Python est configuré de telle sorte qu’il peut être utilisé en ligne de commande ou contrôlé par l’intermédiaire d’un service systemd.
L’exécution en ligne de commande procure l’avantage de produire un affichage des journaux directement sur la console, ce qui facilite l’identification immédiate des erreurs (voir la section Configuration du logger ci-dessus).
Il est essentiel que les paramètres et l’emplacement des fichiers soient configurés comme recommandé dans la section de configuration. Cette section propose d’ailleurs un script bash setup.sh pour faciliter l’installation.
Le script push-to-db.py est présent dans le sous-répertoire /push-to-db/ du dépôt GitHub du projet. Il est également disponible dans le répertoire utilisateur une fois le dépôt GitHub cloné.
Ce script peut être exécuté avec python 2.7 à l’aide des commandes suivantes :
pi@pythonic:~ $ cd la-maison-pythonic/python/push-to-db/
pi@pythonic:~/la-maison-pythonic/python/push-to-db $ python push-to-db.py
Voici les détails des messages affichés durant le fonctionnement du script en mode console.
pi@pythonic:~/la-maison-pythonic/python/push-to-db $ python push-to-db.py
2018-06-25 12:12:16,217 INFO Initializing app
2018-06-25 12:12:16,231 INFO Running app
2018-06-25 12:12:16,232 DEBUG LazyWriter thread started
2018-06-25 12:12:16,340 INFO subscribing maison/rez/salon/temp
2018-06-25 12:12:16,342 INFO subscribing maison/rez/salon/pir
2018-06-25 12:12:16,345 INFO subscribing maison/rez/#
2018-06-25 12:12:16,348 INFO subscribing maison/exterieur/#
2018-06-25 12:12:16,350 INFO subscribing maison/cave/#
2018-06-25 12:12:16,351 INFO subscribing maison/exterieur/cabane/lux
2018-06-25 12:12:16,352 INFO subscribing maison/exterieur/jardin/hrel
2018-06-25 12:12:16,354 INFO subscribing maison/exterieur/jardin/temp
2018-06-25 12:12:16,355 INFO subscribing maison/cave/chaufferie/etat
2018-06-25 12:12:16,357 INFO subscribing maison/cave/chaufferie/temp-eau
2018-06-25 12:12:16,360 INFO mqtt connect return code: 0
2018-06-25 12:13:17,957 INFO getting MQTT message...
2018-06-25 12:13:17,958 INFO topic : maison/cave/chaufferie/temp-eau
2018-06-25 12:13:17,959 INFO message: 22.69
2018-06-25 12:13:17,959 INFO QoS : 0
2018-06-25 12:13:17,960 DEBUG LazyWriter latency_start set to now
2018-06-25 12:15:18,774 INFO getting MQTT message...
2018-06-25 12:15:18,780 INFO topic : maison/cave/chaufferie/temp-eau
2018-06-25 12:15:18,781 INFO message: 22.63
2018-06-25 12:15:18,784 INFO QoS : 0
2018-06-25 12:15:18,961 INFO LazyWriter latency 120 sec reached ->
Process_message_queue.
Le programme peut être interrompu à tout moment en pressant la combinaison de touches [Ctrl] C dans le terminal.
Pour exécuter une commande ou un programme au démarrage du Raspberry Pi, il est nécessaire d’ajouter un « service ». Ce service pourra alors être démarré/arrêté ou activé/désactivé par l’intermédiaire d’une ligne de commande.
Depuis Raspbian Jessie, le démon d’initialisation systemd remplace l’ancien système d’initialisation Système V à base de script. Ainsi donc, pour démarrer automatiquement un programme ou un script, il faut passer par systemd.
Cette section propose de configurer un service push-to-db.service afin de démarrer automatiquement le script push-to-db.py au démarrage du système.
Il est essentiel que les paramètres et l’emplacement des fichiers soient configurés comme recommandé dans la section de configuration. Cette section propose d’ailleurs un script bash setup.sh pour faciliter l’installation.
La séquence d’initialisation du système d’exploitation est relativement longue, même sous systemd.
D’une façon générale, il est préférable de démarrer les scripts Python :
•.Une fois le réseau connecté et disponible.
•.Lorsque l’heure système est mise à jour par le service NTP (Network Time Protocol).
•.Lorsque le répertoire utilisateur est accessible. Le script push-to-db.py se trouve dans le répertoire /home/pi/la-maison-pythonic/python/push-to-db/.
•.Une fois le serveur MQTT Mosquitto démarré (s’il est utilisé sur le Raspberry Pi).
Le fichier Unit sert à stocker la configuration du service et indique à systemd ce qui doit être exécuté et quand cela doit être exécuté.
Utiliser l’éditeur de votre choix pour créer le fichier Unit nommée push-to-db.service. Dans le cas présent, l’éditeur de texte nano est utilisé. La commande est assortie d’un sudo car le répertoire de destination n’est pas libre d’accès.
sudo nano /lib/systemd/system/push-to-db.service
Le fichier push-to-db.service doit contenir les informations suivantes :
[Unit]
Description=Stockage DB MQTT pour la-maison-pythonic
After=multi-user.target
Wants=mosquitto.target
[Service]
Type=idle
ExecStart=/usr/bin/python /home/pi/la-maison-pythonic/python/push-to-db/
push-to-db.py
[Install]
WantedBy=multi-user.target
Le fichier unit doit avoir la permission 644 avant de pouvoir l’intégrer à systemd.
sudo chmod 644 /lib/systemd/system/push-to-db.service
Les différents paramètres du fichier Unit sont les suivants :
•.Wants : indique que le service a besoin du service Mosquitto pour démarrer.
•.After : indique que le service désire démarrer après multi-user (activation de l’environnement multi-utilisateur non graphique). La dépendance After= ne présente qu’une suggestion d’ordre de démarrage.
À noter qu’il est possible d’indiquer les dépendances sous la forme After=multi-user.target mosquitto.target permettant d’atteindre le même résultat, mais sans contrainte.
•.ExecStart : indique la commande à exécuter. Dans le cas présent, il s’agit d’un script Python, il est donc nécessaire d’indiquer l’interpréteur - avec son chemin d’accès - à utiliser.
•.Type : configure le type de démarrage pour le service. Idle indique que l’exécution réelle du service est retardée après le démarrage de toutes les autres tâches.
•.WantedBy : est la manière habituelle d’indiquer comment le service doit être activé.
Maintenant que le fichier Unit est prêt, la commande suivante indique à systemd qu’il faut le démarrer durant la séquence de démarrage.
sudo systemctl deamon-reload
sudo systemctl enable push-to-db.service
À partir de maintenant, le service push-to-db démarrera automatiquement à chaque démarrage du Raspberry Pi. Le service peut être désactivé avec la commande sudo systemctl disable push-to-db.service.
Le service peut être démarré directement avec la commande ci-dessous :
sudo systemctl start push-to-db.service
Enfin, il est possible de contrôler l’état du service à tout moment avec la commande :
sudo systemctl status push-to-db.service
Ce qui fournit de nombreuses informations sur le service comme son statut, l’identification du processus, la commande utilisée et les derniers messages envoyés à la console.
pi@pythonic:~ $ sudo systemctl status push-to-db.service
. push-to-db.service - MQTT database storage service for la-maison-pythonic project
Loaded: loaded (/lib/systemd/system/push-to-db.service; enabled)
Active: active (running) since Mon 2018-06-25 16:07:25 UTC; 1min 2s ago
Main PID: 3719 (python)
CGroup: /system.slice/push-to-db.service
└─3719 /usr/bin/python /home/pi/la-maison-pythonic/python/push-to-...
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,853 INFO subscri...ir
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,855 INFO subscri.../#
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,857 INFO subscri.../#
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,858 INFO subscri.../#
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,860 INFO subscri...ux
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,861 INFO subscri...el
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,862 INFO subscri...mp
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,864 INFO subscri...at
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,866 INFO subscri...au
Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,868 INFO mqtt co... 0
Hint: Some lines were ellipsized, use -l to show in full.
Internet regorge d’informations sur systemd, mais les liens suivants représentent d’excellentes sources d’information :
•.l’article « systemd, tout nouveau tout beau ? Ou pas... » paru sur framboise314.fr : https://www.framboise314.fr/systemd-tout-nouveau-tout-beau-ou-pas/
•.la série d’articles « systemd pour les administrateurs » sur linuxfr.org :
https://linuxfr.org/news/systemd-pour-les-administrateurs-partie-1-et-2
https://linuxfr.org/news/systemd-pour-les-administrateurs-parties-3-4-et-5
•.la liste des unités spéciales : http://0pointer.de/public/systemd-man/systemd.special.html
•.la liste des types : https://www.freedesktop.org/software/systemd/man/systemd.service.html
Sous sa forme actuelle, le script push-to-db.py offre un service relativement élémentaire qui peut recevoir de nombreuses améliorations.
Par exemple :
•.Ajouter de nouveaux connecteurs permettant de stocker des informations dans d’autres bases de données (ex. : MariaDB, MySQL, PosgreSql), fichiers (ex. : CSV, texte, fichier binaire) ou services exotiques (ex. : pousser sur un serveur FTP, sur Tweeter, etc.).
•.Prise en charge de formats de messages spécifiques (ex. : capture d’image).
•.Utiliser un système de fichiers en RAM pour le stockage des bases de données. Cela autorise un nombre illimité de cycles d’écriture sur la base de données, avec une réplication régulière sur support physique (comme la carte SD). Par exemple, une réplication toutes les 2 heures permet de réduire les cycles d’écritures physiques sur la carte SD de 720 par jours (écriture toutes les 120 secondes) à 12 cycles !
•.Bien qu’initialement prévu, le processus de nettoyage des tables d’historique n’a pas été mis en œuvre, c’est donc une amélioration à apporter.
Flask est un micro framework de développement web écrit en Python.
Logo du projet Flask
Allant à contre-pied d’autres solutions de développement web, Flask est livré avec le strict minimum, à savoir :
•.un moteur de template (Jinja 2)
•.un serveur web de développement (Werkzeug)
•.un système de distribution de requête compatible REST (dit RESTful)
•.un support de débogage intégré au serveur web
•.un micro framework doté d’une très grande flexibilité
•.une très bonne documentation
Structure du micro framework Flask
Disposer d’un micro framework implique donc l’absence de certains éléments out-of-the-box tels que :
•.une solution d’authentification
•.le support de base de données ou un ORM
•.la gestion sécurisée de formulaires HTML
•.une interface d’administration
Cela n’est cependant pas un frein, car la grande flexibilité du micro framework permet l’adjonction d’une pléthore d’extensions Flask couvrant ces manques apparents, extensions dont certaines sont décrites plus loin dans ce chapitre.
Pour commencer, parce qu’il utilise Python, ce qui reste dans les objectifs du présent ouvrage.
Flask n’est cependant pas le seul framework de développement web Python disponible. Il existe d’autres alternatives comme Bottle (http://bottlepy.org), Django (https://www.djangoproject.com/) ou CherryPy (https://cherrypy.org).
Django est une perle dans le domaine du développement web Python. Il dispose de nombreuses fonctionnalités et rien n’y manque pour réaliser un développement de niveau professionnel. La contrepartie, c’est une solution plus lourde à mettre en œuvre avec une courbe d’apprentissage plus longue. Django sera plutôt réservé à des projets d’envergure.
Flask et Bottle quant à eux permettent de produire les premières pages en moins de 5 minutes (installation comprise). Ces solutions plus légères en termes de ressources conviendront mieux à notre projet sur Raspberry Pi.
Ce qui a guidé le choix entre Bottle et Flask est la popularité, la documentation et les extensions de Flask. L’expérience ayant par ailleurs démontré que ce dernier permet de réaliser des développements de qualité professionnelle.
Ce qui rend Flask si populaire et attractif, c’est sa grande flexibilité. Dès le début du développement, le micro framework a intégré de nombreux mécanismes facilitant le développement d’extensions.
Ainsi, Flask intègre des mécanismes comme les hooks, extend (développement d’extension), Signals (notification par signaux), dérivation de la classe Flask et middleware (introduction de corrections entre le serveur HTTP et l’application Flask).
Un document est disponible en ligne pour découvrir ces différents mécanismes : http://flask.pocoo.org/docs/1.0/becomingbig/
Ces dernières années ont vu l’apparition de très nombreuses extensions permettant d’ajouter, au cas par cas, les fonctionnalités nécessaires sur le micro framework.
Un catalogue des extensions est disponible sur le lien suivant : http://flask.pocoo.org/extensions/
Les extensions sont hébergées sur des pages PyPI (Python Package Index), ce qui permet de les installer avec l’utilitaire pip comme n’importe quel autre paquet Python.
Le catalogue d’extensions est une liste modérée d’extensions Flask suivant les recommandations du guide des extensions Flask. De nombreuses ressources sont également disponibles sur Internet.
La liste suivante reprend les extensions les plus intéressantes. Savoir quelles extensions existent permet d’éviter de nombreux efforts et des recherches inutiles.
•.Flask-Login : permet d’ajouter la gestion de sessions utilisateur à Flask. Cette extension prend en charge le login, la déconnexion et mémorisation de la session utilisateur durant un certain temps.
•.Flask-User : gestion personnalisable des utilisateurs (enregistrement, confirmation, login, changement de mot de passe, etc.).
•.Flask-Themes : permet à une application Flask de supporter plusieurs thèmes.
•.Flask-WTF : permet de simplifier le code nécessaire à la gestion de la saisie et de la validation de données sur des pages HTML. Avec WTForm, les caractéristiques et les validations des champs de données sont définies dans le script python tandis que le template s’occupe du rendu. Un must !
•.Flask-Uploads : permet de prendre en charge le téléversement et le stockage de fichiers sur le serveur. Cette extension permet également de télécharger les fichiers stockés sur le serveur.
•.Flask-Exceptionnal : permet d’ajouter le support d’Exceptionnal à une application Flask. Exceptionnal (http://www.exceptional.io) collecte les erreurs et leurs informations associées survenant dans les applications et les rend disponibles en temps réel sur un site sécurisé.
•.Flask-SQLAlchemy : apporte le support pour SQLAlchemy à Flask. SQLAlchemy est un ORM (Object Relational Mapper) très puissant supportant de nombreuses bases de données. Un must !
•.Flask-Babel : ajout du support d’internationalisation i18n et localisation l10n à une application Flask en s’appuyant sur le projet Babel. La fonction gettext() et l’utilitaire pybabel permettent de traduire facilement le contenu de l’application.
•.Flask-Bcrypt : algorithme de hashing utilisé pour éviter le stockage des mots de passe dans la base de données.
•.Flask-RESTful : permet de réaliser rapidement des API REST et d’obtenir des ressources au format JSON.
•.Flask-Restless : permet de fournir une API RESTful pour des modèles de base de données en utilisant SQLAlchemy.
•.Flask-Mail : permet à une application Flask d’envoyer très facilement des e-mails.
•.Flask-Creole : permet d’utiliser le langage de marquage Wikicréole dans les applications Flask. Similaire à MarkDown, Wikicréole est une syntaxe universelle pour les wikis destinée à transférer facilement du contenu entre différents moteurs wiki.
•.Flask-Dance : permet d’utiliser la délégation d’autorisation OAuth dans un projet Flask. OAuth est un protocole libre qui permet aux utilisateurs d’un site A de donner l’autorisation à un tiers B (site ou logiciel) d’utiliser les données personnelles stockées sur le site A en son nom (généralement via une API). Le mécanisme OAuth permet de protéger les pseudonyme et mot de passe utilisateur du site A puisqu’ils ne sont jamais communiqués au tiers B. Voir aussi Flask-OpenID et Flask-OAuth.
•.Flask-FlatPages : offre une collection de pages statiques à une application Flask. Les pages sont stockées sous forme de fichiers plats (en opposition aux pages générées à partir d’une base de données). Flask-FlatPages s’utilise en conjonction avec Frozen-Flask (https://pythonhosted.org/Frozen-Flask/).
•.Flask-Genshi : permet à l’application Flask de supporter le langage de template Genshi pour HTML, XML et texte. Genshi met surtout l’accent sur la chaîne de traitement XML, ce qui renforce la conformité des contenus HTML/XML produits. En effet, la plupart des langages de template, y compris Jinja, ne traitent que des flux de caractères pour y réaliser des substitutions sans assurer la conformité du rendu.
Le schéma suivant présente le fonctionnement général du micro framework Flask et les différents éléments intervenant dans la chaîne de production du contenu.
Fonctionnement détaillé du micro framework Flask
Voici une description des différents éléments composant le mini framework Flask :
•.Werkzeug
•.WSGI
•.Jinja
•.la base de données
Werkzeug est un serveur web WSGI écrit en Python.
Il reçoit les requêtes HTTP et fait le nécessaire pour décoder les demandes et envoyer les réponses en retour. Werkzeug s’occupe des problématiques de gestion des sockets et connexions, traitement des tâches en parallèles, prise en charge des aspects sécuritaires (au niveau HTTP), etc.
Werkzeug est également un serveur compatible WSGI. Il est donc capable de transmettre la requête à l’application en suivant le protocole WSGI puis de récupérer la réponse et de la transmettre au client via HTTP.
Bien que ce projet ait débuté comme une simple boîte à outils WSGI, Werkzeug est aujourd’hui l’un des modules WSGI les plus puissants existant sur le marché.
Werkzeug inclut :
•.la gestion complète des requêtes et des réponses HTTP,
•.un support unicode,
•.un débogueur interactif (utilisant JavaScript),
•.un contrôle de l’en-tête HTTP,
•.un contrôle du cache (dans l’en-tête),
•.la gestion des cookies,
•.la transmission de fichiers (téléchargement et téléversement),
•.un système très puissant de routage des URL,
•.de nombreux greffons produits par la communauté,
•.un support WSGI compatible avec Python 2.7 et 3.3.
Dans le cadre d’une solution Flask, Werkzeug :
•.transmet les requêtes HTTP à l’application Flask,
•.délivre directement les ressources statiques telles que les fichiers CSS, JavaScript, les images.
WSGI (Web Server Gateway Interface) est une norme et un protocole de communication qui définissent comment un serveur web peut interagir avec une application Python pour envoyer des requêtes et recevoir des réponses.
WSGI est une norme pour serveur web comme l’est FastCGI, SCGI, ou WebSocket destinée au langage Python.
Un serveur web supportant WSGI doit être capable de transformer une requête HTTP en objet Python en suivant la norme WSGI. Il doit également être capable de recevoir la réponse sous forme d’objet Python pour renvoyer celle-ci vers le client web.
L’application Python (comme Flask) doit donc être capable de supporter les spécificités du protocole WSGI pour renvoyer des réponses par l’intermédiaire du serveur web via WSGI. L’application doit pouvoir recevoir une requête HTTP sous forme d’un objet Python (à la norme WSGI) et renvoyer la réponse avec un objet Python tel que défini dans la norme.
Tous les serveurs compatibles WSGI le sont avec toutes les applications compatibles WSGI. La norme WSGI assure que ces différents éléments sont interchangeables.
Avantages de WSGI
•.Pas besoin de prendre en charge le support HTTP dans l’application.
•.Normalisation de l’interface entre l’application et le serveur web. Il est possible de changer de serveur web si cela est nécessaire.
•.Le module WSGI reste chargé en mémoire (économie de ressources, meilleur temps de réponse).
•.WSGI permet l’interfaçage direct d’un code Python. Une application rudimentaire ne nécessite pas l’utilisation d’un quelconque framework pour produire une réponse pour le web serveur.
Inconvénients du WSGI
•.WSGI ne supporte pas encore la norme WebSocket, devenue rapidement populaire car elle permet au serveur de pousser des données vers le client web en fonction des besoins. WebSocket permet une communication dans les deux sens. Il existe cependant des solutions Python alternatives telles que Tornado ou Twisted.
•.Certains serveurs comme Nginx ou lighttpd ne supportent pas WSGI, ce qui empêche l’application Python de communiquer directement avec ces serveurs web. Il existe cependant des solutions middlewares, comme par exemple gunicorn qui permet de réaliser une installation nginx (serveur web) <==> gunicorn (middleware WSGI) <==> Flask / Django / … (Python).
Les serveurs web compatibles WSGI
Voici une liste non exhaustive de serveurs web supportant WSGI :
•.Werkzeug (du projet Flask)
•.CherryPy
•.Django
•.Apache (avec le module mod_wsgi)
Une liste complète est disponible sur https://wsgi.readthedocs.io/en/latest/.
L’application utilisant le framework Flask traite les requêtes entrantes (communiquées par le serveur via WSGI) et produit les réponses correspondantes.
L’application Flask contient des « routes » permettant de réceptionner et de traiter les requêtes des clients pour produire les réponses correspondantes.
Pour réaliser cette tâche, l’application peut utiliser des connexions vers des bases de données afin d’obtenir les éléments nécessaires au rendu de la page. La connexion sur une base de données peut être établie soit en utilisant les modules Python disponibles, soit en utilisant des extensions Flask comme Flask-SQLAlchemy (ORM) ou Flask-CouchDB.
De même, l’application Flask peut produire directement du contenu HTML, CSV, JSON, XML, texte et autre, mais peut également s’appuyer sur le moteur de template de Jinja.
Jinja est un puissant moteur de template permettant de formater et d’inclure les données produites ou collectées par l’application dans les documents. Bien que Jinja est principalement utilisé pour produire du contenu HTML, il peut également produire du contenu XML, LaTeX, CSV, etc.
Les documents HTML contiennent des balises spéciales interprétées et remplacées par le moteur de template afin de produire le document définitif.
Jinja supporte le traitement de structures de contrôle (test, boucle, assignation, comparaison, etc.), de filtres, d’expressions que l’on retrouve dans de nombreux moteurs de templates auquel viennent se joindre de puissantes fonctionnalités telles que l’héritage, les extensions, l’inclusion de template et de « blocks ».
Par défaut, le micro framework de Flask n’inclut pas de support spécifique d’une base de données. Le développeur est donc libre de choisir le support de moteur de base de données de son choix, pour autant que celui-ci soit disponible dans l’environnement Python.
Il est également possible de faire appel à un ORM (Object Relationnal Mapper) tel que SQLAlchemy, ce qui en plus des fonctionnalités ORM ouvre la voie vers de nombreuses bases de données telles que Firebird, Microsoft SQL Server, MySQL, Oracle, PostgreSQL, SQLite, Sybase.
Les extensions Flask apportent également le support d’autres bases de données telles que CouchDB, FluidDB, MongoDB, ZODB.
Pour finir, Python supporte différentes bases de données via la DB-API (Database API) et celles-ci peuvent, bien entendu, être utilisées dans une application Flask. C’est le cas du présent projet avec une base de données SQLite.
ORM signifie Object-Relational Mapping. Il s’agit d’une technique de programmation qui permet d’accéder à une ou plusieurs bases de données relationnelles sous forme d’objets et de collection d’objets. Allant bien au-delà des concepts de tables, lignes et colonnes, un ORM permet de créer une structure de données objet offrant des méthodes et des propriétés avancées pouvant être vues comme une « base de données virtuelle » capable de joindre et manipuler des sources de données a priori incompatibles entre elles. Un ORM est donc un middleware entre la base de données et l’application.
L’une des grandes forces de Flask est la qualité de sa documentation. Cette documentation est disponible sur les liens suivants :
•.documentation officielle : http://flask.pocoo.org/docs/1.0/
•.communauté, canal IRC, Stack Overflow, etc. : http://flask.pocoo.org/community/
L’élément le plus important d’un projet Flask est la structure des répertoires utilisée pour stocker les différents éléments du projet. Les quelques premiers fichiers qui y sont créés sont également cruciaux.
Si la structure n’est pas scrupuleusement respectée, rien n’y fera, le projet ne produira pas de résultats.
Voici la structure de base du projet Flask pour le projet « mon-projet » avec quelques éléments complémentaires pour illustrer la mise en place.
mon-projet/
└── app
├── __init__.py
├── static
│ ├── website.css
│ ├── datagrid.css
│ ├── ico-new.png
│ ├── ico-top.png
│ └── logo.png
├── templates
│ ├── index.html
│ ├── base.html
│ └── entries.html
├── config.py
├── views.py
└── models.py
Pour commencer, le projet est stocké dans son propre répertoire nommé « mon-projet », ce qui permettra de centraliser de nombreuses ressources comme la documentation, des spécifications, les autres scripts Python, l’environnement virtuel Python (virtualenv) ou les ressources indirectement liées au projet à développer.
Les éléments du développement Flask prennent tous place dans un sous-répertoire APP.
•.Répertoire mon-projet : répertoire de base du projet contenant diverses ressources, y compris le sous-répertoire app destiné au développement Flask.
•.Répertoire app : répertoire où est développé le projet Flask. Le projet Flask est vu comme un package, raison de la présence du fichier __init__.py.
•.Fichier __init__.py : le fichier très important du projet puisqu’il contient la création de l’objet applicatif Flask (qui sera appelé par le serveur web Werkzeug), application qui prendra en charge les différentes requêtes.
•.Répertoire static : ce dernier contient toutes les ressources statiques de l’application Flask. Cela concerne principalement les feuilles de styles (CSS), fichiers JavaScript, les images et fichiers délivrés tels quels au client. Le répertoire static peut être subdivisé en sous-répertoires pour répartir les fichiers par type (ex. : un sous-répertoire images), ce qui est de bon ton pour les projets de plus grande envergure.
•.Répertoire templates : ce répertoire contient les fichiers templates qui seront utilisés par le moteur de template Jinja pour produire les réponses renvoyées par l’application. Dans les développements d’envergure, il est d’usage d’utiliser des sous-répertoires dans templates pour séparer les éléments par domaine d’application (ex. : app/templates/admin/ pour les pages destinées à l’administration).
•.Fichier views.py : fichier qui apparaît rapidement dans un projet Flask dès lors que plusieurs requêtes doivent être prises en charge. Bien que cela ne soit pas indispensable, il est conseillé de scinder le traitement des requêtes (les vues, fichier views.py) du cœur de l’application (fichier __init__.py). La tâche principale du fichier views.py est de préparer quelques informations puis de faire appel au moteur de template pour produire la réponse à retourner. Il est assez courant de rencontrer le nom de fichier routes.py en lieu et place de views.py.
•.Fichier models.py : bien qu’optionnel, ce fichier modèle permet de stocker les différentes fonctions, classes et méthodes permettant d’obtenir les données nécessaires au rendu des pages.
•.Fichier config.py : également optionnel, ce fichier permet de définir des constantes et les informations de configuration intégrées au sein même du micro framework Flask.
Un environnement virtuel Python (VirtualEnv) permet de disposer d’une configuration spécifique de Python pour un projet donné. Cela permet d’installer des modules et des bibliothèques Python pour un projet sans altérer, polluer ou détériorer la configuration générale de Python. Par convention, l’environnement virtuel est habituellement stocké dans le sous-répertoire venv du répertoire projet (donc mon-projet/venv/). Il est toujours intéressant de mentionner l’existence des environnements virtuels Python même si cela sort du cadre de l’ouvrage.
L’installation de Flask sur Raspberry Pi se fait à l’aide de l’utilitaire pip (Python Install Package) :
sudo pip install Flask
Puis, le script suivant peut être saisi dans le fichier flask-minimal.py à l’aide de la commande nano flask-minimal.py. Une copie de ce fichier est disponible dans le dépôt GitHub du projet dans le sous-répertoire python/divers/.
01: # coding: utf8
02: # Importer la bibliothèque Flask
03: from flask import Flask
04:
05: # Initialisze l’application Flask
06: app = Flask( __name__ )
07:
08: # Définir une route pour capturer la requête
09: # et produire la réponse avec la fonction
10: # dit_bonjour()
11: @app.route(’/’)
12: def dit_bonjour():
13: return ’Salut tout le monde!’
14:
15: # Démarrer l’application sur le port 8085
16: app.run( debug=True, port=8085, host=’0.0.0.0’)
Les commentaires du code sont explicites, certaines lignes méritent cependant quelques explications complémentaires.
Ligne 11 : utilisation du décorateur app.route pour définir une route associée à l’URL « / » (racine du site) avec la fonction dit_bonjour().
Ligne 13 : retourne une chaîne de caractères comme contenu de la réponse (sans aucun formatage HTML).
Ligne 16 : démarrage de l’application et activation du serveur web en mode de débogage. Le paramètre host=’0.0.0.0’ permet au serveur d’accepter des connexions externes et donc de tester Flask depuis une autre machine du réseau.
Le paramètre host=’0.0.0.0’ concerne plutôt une situation de mise en production. Lorsqu’il est omis, les seules connexions autorisées sont celles depuis l’hôte local (donc localhost:8085 ou 127.0.0.1:8085). De même, il est vivement conseillé de désactiver le débogueur (debug=False) lors d’une mise en production.
Flask permet de définir le port (ici 8085) sur lequel le serveur web sera actif. Les requêtes du navigateur sont adressées au port 80, mais il est probable que le port 80 du futur serveur web soit déjà occupé par un processus ou fortement protégé par un serveur web. C’est pour cette raison que le serveur web de l’application utilise un port différent (8085 ou, plus commun, le port 5000).
Une fois le script prêt, le serveur web est lancé avec la commande python python-minimal.py. Ce qui produit le résultat suivant lorsque la page racine « / » est demandée depuis un navigateur internet. Le programme Python peut être interrompu à tout moment avec la combinaison de touches [Ctrl] C.
Cet exemple utilise volontairement le port 8085 au lieu du port 5000 largement préféré dans les applications Flask. En modifiant le port utilisé, il est possible de faire cohabiter plusieurs applications Flask distinctes sur un seul et même hôte (chaque application écoutant les requêtes entrant sur un port différent).
pi@pythonic:~ $ python flask-minimal.py
* Running on http://0.0.0.0:8085/
* Restarting with reloader
192.168.1.22 - - [05/Jul/2018 15:11:09] "GET / HTTP/1.1" 200 -
192.168.1.22 - - [05/Jul/2018 15:11:10] "GET /favicon.ico
HTTP/1.1" 404 -
192.168.1.22 - - [05/Jul/2018 15:11:10] "GET /favicon.ico HTTP/1.1" 404 -
Obtention de la page racine depuis un navigateur sur le réseau local
Si le navigateur ne peut pas résoudre le nom de l’hôte sur le réseau local (pythonic.local), alors il sera nécessaire d’utiliser l’adresse IP assignée au Raspberry Pi (192.168.1.210 dans le présent cas).
Le micro framework inclut également un utilitaire en ligne de commande nommé flask. Ce dernier prend en charge l’exécution de l’application Flask (ce qui correspond à la ligne 16 qui doit alors être omise).
01: # coding: utf8
02: # Importer la bibliothèque Flask
03: from flask import Flask
04:
05: # Initialisze l’application Flask
06: app = Flask( __name__ )
07:
08: # Définir une route pour capturer la requête
09: # et produire la réponse avec la fonction
10: # dit_bonjour()
11: @app.route(’/’)
12: def dit_bonjour():
13: return ’Salut tout le monde!’
14:
15: # Demarrage assuré par utilitaire flask
16: # COMMENT !!! app.run( debug=True, port=8085, host=’0.0.0.0’)
L’utilitaire flask utilise les variables d’environnements FLASK_APP et FLASK_DEBUG.
Il s’utilise comme suit :
export FLASK_APP=/home/pi/flask-minimal.py
export FLASK_DEBUG=1
flask run
Ce qui produit l’affichage suivant à la mise en route de l’application :
pi@pythonic ~ $ flask run
* Serving Flask app "flask-minimal"
* Forcing debug mode on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 252-768-511
Si cette approche est intéressante, elle manque cruellement de flexibilité, car le port est fixé et l’hôte restreint aux connexions locales (problématique pour un développement sur Raspberry Pi puisque le navigateur de test est généralement sur une autre machine du réseau). Le port est fixé à 5000 (valeur par défaut pour Flask).
Dans la suite de ce chapitre, il sera parfois nécessaire d’utiliser des ressources plus élaborées. Il sera par conséquent nécessaire de faire appel à la structure des répertoires présentés ci-avant dans le chapitre (cf. Anatomie d’un projet Flask dans ce chapitre).
Cette section reprend l’exemple « Salut tout le monde ! » en suivant scrupuleusement la hiérarchie des répertoires et en produisant un rendu HTML rudimentaire.
Par ailleurs, un petit script Python nommé runapp.py est utilisé pour remplacer l’utilitaire flask. Cela permet de contrôler plus finement le démarrage de l’application et, entre autres, d’autoriser le mode de débogage pour les connexions externes.
Les fichiers et les répertoires sont disponibles dans le dépôt GitHub du projet sous le répertoire python/flask-demos/minimal-app/.
Voici la liste des fichiers et répertoires utilisés :
minimal-app
├── app
│ ├── static
│ ├── templates
│ ├── __init__.py
│ ├── config.py
│ ├── models.py
│ └── views.py
└── runapp.py
Ne disposant pas de ressources particulières, les répertoires static et templates sont actuellement vides.
Le fichier config.py contient une constante de configuration arbitrairement nommée PARAMS.
# coding: utf8
PARAMS = { ’nom’: ’Casimir’, ’age’ : 5 }
Vient ensuite le fichier views.py qui associe la fonction dis_bonjour() avec l’URI « / ».
Cette fois, la fonction retourne un contenu HTML plus étoffé.
# coding: utf8
from app import app
# importer les autres éléments déclarés
# dans /app/__init__py selon les besoins
#
# from app import db, babel
# importer les modèles pour accéder
# aux données
#
from app.models import *
@app.route(’/’)
def dit_bonjour():
# Récupération de PARAMS (config.py)
p = app.config[’PARAMS’]
return """<!doctype html>
<html>
<head>
<title>Titre de la page</title>
</head>
<body>
<h1>Dis bonjour!</h1><br />
Bonjour tout le monde, je suis %s et j’ai %s ans!
</body>
</html>""" % (p[’nom’],p[’age’])
Le début du script views.py contient de nombreuses lignes en commentaire, ces dernières concernent l’inclusion d’éléments relatifs à des projets plus importants comme l’objet db pour accéder à une session de base de données, babel pour les traductions, etc.
L’inclusion du fichier models.py est volontaire même s’il est actuellement vide. Ce fichier est destiné à contenir des fonctions et des méthodes permettant d’accéder à des données dès lors qu’il y a une base de données impliquée dans le projet.
Vient ensuite la prise en charge de la requête « / » avec le décorateur app.route(’/’) et la fonction dit_bonjour(). Notez la récupération de la structure PARAMS (telle que définie dans config.py) avec p = app.config[’PARAMS’].
Remarquez que app.config est un simple dictionnaire Python.
Le fichier __init__.py
Le répertoire app étant constitué comme un package Python, ce dernier doit contenir un fichier d’initialisation intitulé __init__.py. Dans le cadre d’une application Flask, ce fichier est très important puisqu’il initialise tous les éléments clés de l’application (objet application, session de base de données, chargement de la configuration, mise en place des routes).
01: # coding: utf8
02: # Importer la bibliothèque Flask
03: from flask import Flask
04: from app import config
05:
06: # Initialise l’application Flask
07: app = Flask( __name__ )
08: app.config.from_object(config)
09:
10: # Création d’autres ressources
11: # db = SQLAlchemy( app )
12: # babel = Babel( app )
13:
14: from app import views
15: from app import models
•.Ligne 3 : import de la classe Flask.
•.Ligne 4 : import de la configuration (config.py) depuis le package app.
•.Ligne 7 : création de l’objet app, l’object applicatif Flask.
•.Ligne 8 : lecture et intégration des paramètres de configuration contenus dans config.py.
•.Lignes 10 à 13 : exemple d’intégration d’extensions Flask (ORM Alchemy et internationalisation Babel, lignes en commentaires).
•.Ligne 14 : importation de views.py, ce qui active les routes pour la capture des différentes requêtes. Il est important que cette ligne figure en bas du fichier d’initialisation.
•.Ligne 15 : importation de models.py (vide dans le cas présent) offrant l’accès aux données et leur manipulation.
Le fichier runapp.py
Optionnel, le fichier runapp.py situé dans le répertoire racine du projet permet de démarrer facilement l’application Flask avec le paramétrage souhaité.
L’avantage d’un tel fichier est qu’il n’est pas nécessaire de mémoriser comment démarrer l’application Flask. Son nom laisse clairement deviner son utilité !
Le fichier runapp.py est très simple, il importe l’instance app (créé dans __init__.py) du package app (le répertoire app dans le projet).
Ensuite, la méthode run() est appelée sur cette instance pour démarrer le projet.
#!/usr/bin/env python
# coding: utf8
from app import app
app.run( debug=True, host=’0.0.0.0’, port=5000 )
# Production
#app.run( host=’0.0.0.0’ )
La toute première ligne inclut un shebang qui indique au système l’interpréteur de commande à utiliser (Python).
Cela permet d’exécuter directement le script depuis une ligne de commande avec « ./runapp.py » si l’attribut d’exécution est activé sur ce script Python avec la commande chmod +x runapp.py.
L’exécution du script produit le résultat suivant dans la console lorsque la page est générée.
pi@pythonic:~/minimal-app $ ./runapp.py
* Running on http://0.0.0.0:5000/
* Restarting with reloader
192.168.1.22 - - [06/Jul/2018 13:15:42] "GET / HTTP/1.1" 200 -
192.168.1.22 - - [06/Jul/2018 21:56:57] "GET / HTTP/1.1" 200 -
La page générée en appelant la page racine du site mis à disposition par l’application Flask sur le Raspberry Pi ressemble à ceci :
Résultat produit par route (’/’) avec inclusion de PARAMS
L’adresse IP du Raspberry Pi peut être utilisée en lieu et place du nom de la machine sur le réseau. Tel que défini dans le script runapp.py, le port utilisé est 5000.
Tout comme n’importe quel script Python, runapp.py peut être interrompu à l’aide de la combinaison de touches [Ctrl] C.
Le contenu de l’application minimale va être modifié de sorte à produire une erreur.
Ensuite, le débogueur de Flask sera activé afin de pouvoir capturer l’erreur.
Une copie du fichier flask-debugger.py, dont le code est repris ci-dessous, est disponible dans le dépôt GitHub du projet dans le sous-répertoire python/flask-demos/.
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask
# Initialise l’application Flask
app = Flask( __name__ )
@app.route(’/’)
def racine():
return ’Appeler /kaboum pour créer une erreur!’.decode(’utf8’)
@app.route(’/kaboum’)
def kaboum():
val1 = 10
val2 = 0
# Créer une erreur "division par 0"
return ’Résultat = %s’.decode(’utf8’) % (val1/val0)
# Demarrer l’application sur le port 5000
app.run( debug=True, port=5000, host=’0.0.0.0’)
La route @app.route(’/kaboum’) est destinée à produire volontairement une exception ZeroDivisionError.
La dernière ligne du script démarre l’application Flask en activant le débogueur avec le paramètre debug=True.
Après avoir démarré l’application Flask avec python flask-debugger.py, l’appel de l’URL http://192.168.1.210:5000/kaboum produit l’affichage d’une erreur et l’activation du débogueur Flask. Ce dernier affiche la pile d’appels avec le détail de l’erreur.
Activation du débogueur Flask et affichage de la pile d’appel
Placer la souris au-dessus du détail d’une ligne (par exemple la ligne 17, celle provoquant l’erreur) affiche deux icônes d’options visibles sur la droite.
Déplacer la souris au-dessus d’une ligne active les icônes d’option
Ces icônes permettent respectivement d’activer une « console Python » et d’inspecter le code.
Inspecter le code dans le débogueur
Icône inspection code
En cliquant sur l’icône d’inspection, le débogueur affiche le code source du script correspondant à la ligne ciblée.
Affichage du code source du script Python
Console Python
Icône de la Console Python
En cliquant sur l’icône Console Python, le débogueur active une ligne de commande interactive dans le code Python et dans le contexte de l’application Flask.
La console Python permet d’inspecter les variables, les objets et leurs états, d’évaluer des expressions, permet la création d’instances, d’effectuer des appels de fonctions et de méthodes.
Utilisation de la console Python
La console Python offre également la fonction dump() très pratique et pouvant être utilisée de deux façons différentes :
•.dump() : sans paramètres, la fonction affiche toutes les variables locales (et leurs valeurs) dans le contexte actuel.
•.dump(obj) : avec un objet en paramètre, la fonction dump(obj) affiche également les propriétés et les méthodes de l’objet passé en paramètre.
Lors d’une mise en production de l’application (ou lorsque celle-ci accepte des connexions externes), il est vivement recommandé de désactiver le débogueur Flask.
L’activation ou non du débogueur a lieu au moment de l’exécution de l’instance Flask avec sa méthode run().
Le débogueur Flask est désactivé en omettant le paramètre debug ou en le plaçant volontairement à False.
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask
# Initialisze l’application Flask
app = Flask( __name__ )
...
...
...
# Demarrer l’application sur le port 5000
app.run( debug=False, port=5000, host=’0.0.0.0’)
Si l’application Flask était démarrée avec le paramètre debug=False, alors l’appel de l’URL http://192.168.1.210:5000/kaboum (voir exemple au point précédent) produit toujours une exception, mais le détail de celle-ci est masqué derrière une page d’erreur 500 (Erreur interne serveur).
Désactiver le mode de débogage affiche une erreur 500 en cas d’erreur
Afin de simplifier la compréhension des exemples, la découverte des fondamentaux de Flask utilise principalement l’approche minimaliste, à savoir dans un unique script Python.
Il faut cependant garder en mémoire que ces fondamentaux prennent néanmoins place dans la structure des répertoires d’un projet Flask (cf. Anatomie d’un projet Flask dans ce chapitre). En conséquence, les exemples de code, de fonctions et de décorateurs devraient prendre place dans une organisation de fichiers similaire à mon-projet/app/views.py.
Déjà abordé à plusieurs reprises, le décorateur route permet d’associer une fonction de traitement à une URL. Les décorations route prennent généralement place dans un fichier nommé views.py (ou routes.py).
Le décorateur route prévoit le passage de paramètres dans l’URL, paramètres transmis à la fonction de traitement.
L’exemple ci-dessous présente différents cas de capture de paramètres sur les requêtes.
L’exemple est également disponible sur le dépôt GitHub du projet à l’emplacement suivant : /python/flask-demos/url-params/flask-url-params.py.
01: # coding: utf8
02: # Importer la bibliothèque Flask
03: from flask import Flask, request
04:
05: # Initialiser l’application Flask
06: app = Flask( __name__ )
07:
08: # Définir une route pour capturer la requête /param
09: @app.route(’/param/<version_id>’)
10: def montrer_parametre( version_id = -1):
11: # retourne du contenu sous format texte
12: return ’Le paramètre est %s’.decode(’utf8’) % version_id
13:
14: @app.route(’/prendre-id/<element_id>’)
15: @app.route(’/prendre/<element_nom>’)
16: def prend_element( element_id = None, element_nom = None):
17: # traiter les cas d erreur
18: if not( element_id or element_nom ):
19: return ’bad request!’, 400
20:
21: # retourne du contenu sous format texte
22: if element_id:
23: return ’Le paramètre est numérique avec
24: %s’.decode(’utf8’) % element_id
25: else:
26: return ’Le paramètre est textuel avec
27: %s’.decode(’utf8’) % element_nom
28:
29: # Utilisation structure plusieurs?nom=Mhoa&prenom=Champion
30: @app.route(’/plusieurs’)
31: def plusieurs():
32: param_nom = request.args.get(’nom’)
33: param_prenom = request.args.get(’prenom’)
34:
35: return "Test de plusieurs parametres." + \
36: " Nom=%s et Prénom=%s".decode(’utf8’) %
37: (param_nom, param_prenom)
38:
39: # Plusieurs paramètres dans la route
40: @app.route(’/plusieurs2/<nom>/<prenom>’)
41: def plusieurs2(nom,prenom):
42: return "2ième test parametres.".decode(’utf8’) + \
43: " Nom=%s et Prénom=%s".decode(’utf8’) % (nom,
44: prenom)
45:
46: @app.route(’/demo/<int:id>’)
47: def montrer_demo( id ):
48: # retourne du contenu sous format texte
49: return ’Le paramètre entier demo est %s’.decode(’utf8’)
50: % id
51:
52: @app.route(’/demo/<string:id>’)
53: def montrer_demo2( id ):
54: # retourne du contenu sous format texte
55: return ’Le paramètre string demo est %s’.decode(’utf8’)
56: % id
57:
58:
59: # Démarrer l’application sur le port 5000
60: app.run( debug=True, port=5000, host=’0.0.0.0’)
•.Ligne 6 : création de l’application Flask.
•.Ligne 9 : définition de la route « /param/<version_id> » qui permet de saisir des URL avec paramètre comme http://192.168.1.210:5000/param/120, mais n’autorise pas l’appel sans paramètre comme http://192.168.1.210:5000/param.
•.Ligne 10 : définition de la fonction de traitement de la route. Le paramètre par défaut n’est applicable que si la fonction montrer_parametre est appelée depuis une autre fonction.
•.Ligne 12 : retourne un contenu texte vers le navigateur. Le script est encodé en UTF8 (voir la première ligne stipulant « #coding : utf8 »). Comme le script est exécuté en Python 2.7, la chaîne de caractères dans le script n’est pas une chaîne unicode (comme en Python 3). La méthode decode(’utf8’) décode la chaîne de caractères (string) encodée en utf8 pour créer explicitement la chaîne de caractères en unicode. Note : type( "é".decode(’utf8’) ) produit le résultat <type ’unicode’>.
•.Lignes 14 à 16 : association de deux routes « /prendre-id/<element_id> » et « /prendre/<element_nom> » qui ont la même fonction prend_element( element_id=None , element_nom=None ). La fonction définit des valeurs par défaut pour chacun des paramètres, car suivant l’URL appelée http://192.168.1.210:5000/prendre-id/120 ou http://192.168.1.210:5000/prendre/uneValeur, l’un ou l’autre des paramètres est initialisé et communiqué à la fonction de traitement.
•.Ligne 18 : teste qu’au moins un des paramètres est initialisé. A priori, il est impossible d’avoir les deux paramètres à None si l’on passe par les routes. Par contre, rien n’empêche d’appeler la fonction prend_element() depuis une autre fonction, et dans ce cas particulier il faut interdire l’emploi de deux valeurs None.
•.Ligne 19 : retourne une page d’erreur 400 avec le message « bad request! ».
•.Ligne 22 : teste si le paramètre element_id est assigné (différent de None) et exécute la ligne 23 ou 26 en conséquence.
•.Lignes 30 à 31: définition de la route « /plusieurs » et de la fonction de traitement plusieurs().
•.Lignes 32 à 33 : l’utilisation d’une route simple n’empêche pas d’ajouter des paramètres dans la query string, comme par exemple http://192.168.1.210:5000/plusieurs?nom=Dino&prenom=Roustin. L’utilisation de request.args.get( un_nom ) permet de récupérer un paramètre de la query string si ce dernier est présent (sinon la fonction retourne la valeur None).
•.Lignes 40 à 41 : définition de la route « /plusieurs2/<nom>/<prenom> » permettant de capturer deux paramètres sur l’URL.
•.Ligne 46 : définition d’une route « /demo/<int:id> » avec un paramètre typé. Cela permet de rejeter les requêtes où le paramètre id n’est pas un entier. Le paramètre id est typé ’int’ avant l’exécution du code. À noter que seules les valeurs d’id ≥ 0 seront acceptées.
•.Lignes 52 à 55 : définition d’une route identique à la ligne 46 « /demo/<string:id> » à l’exception qu’elle capture le paramètre id sous forme d’une chaîne de caractères.
Si le paramètre communiqué est un entier http://192.168.1.210:5000/demo/18, alors c’est la route de la ligne 46 qui capture la requête. Si le paramètre est une chaîne de caractères http://192.168.1.210:5000/demo/un-mot, alors c’est la route de la ligne 52 qui capture la requête. À noter qu’une valeur en virgule flottante (3.1415) n’étant pas un entier, c’est la route « /demo/<string:id> » qui capturera la requête.
Démarrer le script
Une fois le script démarré avec la commande python flask-url-params.py, il est possible de tester les différents cas de figure énumérés ci-dessous.
Bien que l’adresse IP soit utilisée dans les requêtes ci-dessous, l’utilisateur reste libre d’utiliser le nom d’hôte (ex. : pythonic.local) en lieu et place de l’adresse IP (192.168.1.210). Le port utilisé est 5000 comme précisé dans l’instruction app.run().
Route avec un paramètre
La route @app.route(’/param/<version_id>’) permet de capturer une URL comme celle-ci : http://192.168.1.210:5000/param/faire-un-test
Ce qui affiche le résultat suivant :
Capturer un paramètre sur une route
Où il est possible de constater l’inclusion du paramètre dans la requête.
Bien que la fonction montrer_parametre( version_id = -1 ) dispose d’un paramètre avec une valeur par défaut, il n’est pas possible d’appeler l’URL sans paramètre (/param), car il n’y a pas de route correspondante permettant de capturer cette URL (ex. : @app.route(’/param’)).
Exemple d’erreur lorsqu’il n’y a pas de route définie correspondant à la requête
Plusieurs routes - une seule fonction de traitement
Dans l’exemple « /prendre... », plusieurs routes différentes sont associées à une seule fonction de traitement. Ce qui peut être le cas lors d’une recherche d’un élément sur un identifiant unique (ex. : un id interne) ou sa représentation alternative (ex. : code-barre ou nom d’article) que la fonction résout vers l’identifiant unique avant de produire la réponse.
La route @app.route(’/prendre-id/<element_id>’) permet de capturer une URL comme celle-ci : http://192.168.1.210:5000/prendre-id/120
Capture d’une URL pour un paramètre numérique
Alors que la route @app.route(’/prendre/<element_nom>’) permet, elle, de capturer une URL différente avec cette fois un paramètre de type texte.
Par exemple :
http://192.168.1.210:5000/prendre/chaussette
Ce qui produit le résultat suivant :
Capture d’une URL pour un paramètre alphanumérique
Route avec plusieurs paramètres
Il est également possible d’utiliser une route telle que @app.route(’/plusieurs2/<nom>/<prenom>’) permettant de réceptionner plusieurs paramètres.
Par exemple :
http://192.168.1.210:5000/plusieurs2/Dino/Casimir
Ce qui produit le résultat suivant :
Capture de plusieurs paramètres sur une route
Bien que pratique, cette approche a l’inconvénient de devoir prévoir tous les cas de figure à l’avance (absence de l’un ou l’autre des paramètres) afin de dresser une liste de toutes les routes possibles. En effet, la saisie de http://192.168.1.210:5000/plusieurs2/Dino produira une erreur 404 (Page Not Found), car il y manque le second paramètre.
Capture des paramètres de la query string
La query string est la partie de l’URL ne faisant pas partie du chemin d’accès à la ressource. Par exemple, dans l’URL http://exemple.be/chemin/vers/page?nom=pythonic&logo=serpent, la query string débute après le caractère « ? » et contient « nom=pythonic&logo=serpent ». La query string contient des paramètres sous la forme « clé=valeur », paramètres séparés par une esperluette (&). Dans l’exemple précité, il y a deux paramètres : nom et logo dont les valeurs sont respectivement pythonic et serpent.
Le micro framework Flask permet de récupérer facilement les paramètres de la query string comme le démontre la route @app.route(’/plusieurs’).
La route permet de capturer la requête http://192.168.1.210:5000/plusieurs mais accepte également les URL avec paramètres dans la query string comme http://192.168.1.210:5000/plusieurs?nom=Dino&prenom=Roustin.
Capture de paramètres sur la query string
Les valeurs des paramètres de la query string peuvent être extraites à l’aide de l’objet request (par exemple : param_prenom = request.args.get(’prenom’)).
L’objet request offre l’avantage de permettre une capture sans erreur des paramètres dans la query string, même si ceux-ci en sont absents.
Contrairement à la route @app.route(’/plusieurs2/<nom>/<prenom>’), la route @app.route(’/plusieurs’) permet d’exécuter la fonction plusieurs() avec ou sans paramètre comme le démontre la capture suivante.
Exemple de capture sans paramètre dans la query string
Routes avec des paramètres typés
Le script de démonstration définit deux routes « /demo/<int:id> » et « /demo/<string:id> » correspondant respectivement aux fonctions montrer_demo( id ) et montrer_demo2( id ). À noter que les deux routes présentes ont une racine identique (/demo) et que la seule différence réside dans le type du paramètre.
La saisie de l’URL http://192.168.1.210:5000/demo/18 produit le résultat suivant où le message indique clairement que c’est le paramètre sous forme d’entier qui a été capturé. C’est la fonction montrer_demo() qui a traité la requête.
Capture d’un paramètre typé comme entier
Par contre, la saisie de l’URL http://192.168.1.210:5000/demo/un-mot produit le résultat suivant où, cette fois, le message indique que la donnée est une chaîne de caractères (requête prise en charge par la fonction montrer_demo2().
Capturer un paramètre typé comme une chaîne de caractères
À noter qu’il n’y a pas de prise en charge spécifique pour une valeur à virgule flottante et par conséquent, l’URL http://192.168.1.210:5000/demo/3.1415 sera prise en charge par le type de donnée le plus adapté qui est la chaîne de caractères.
Cas de la valeur en virgule flottante
Les types de paramètres des routes
Flask supporte plusieurs types de paramètres, appelés converters dans la littérature sur le sujet :
•.string : accepte du texte sans caractère slash (« / »). String est le type par défaut.
•.int : accepte des entiers, mais pas des valeurs négatives.
•.float : accepte des valeurs en virgule flottante.
•.path : accepte du texte avec le caractère slash.
Retourner une page d’erreur avec Flask est assez simple. Le module flask propose la fonction abort() qui permet d’interrompre le fonctionnement d’une fonction de traitement en retournant un code d’erreur HTML.
Dans le script suivant, la route @app.route(’/mon-erreur/<int:id>’) permet de retourner une erreur 404 (Page non trouvée) si le paramètre id est supérieur à 99.
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask, abort
# Initialisze l’application Flask
app = Flask( __name__ )
@app.route(’/’)
def racine():
return ’Appeler /mon-erreur avec id<100 ou
id>=100’.decode(’utf8’)
@app.route(’/mon-erreur/<int:id>’)
def demo( id ):
if id > 100:
# Retourner une page d’erreur 404
abort(404)
else:
return ’C est tout bon’
# Demarrer l’application sur le port 5000
app.run( debug=True, port=5000, host=’0.0.0.0’)
La fonction abort() prend un code d’erreur HTML ou un objet Response en paramètre et termine prématurément le traitement de requête.
L’utilisation la plus commune de la fonction abort() est le code d’erreur HTML (400 et suivant).
Les codes d’erreur HTML concernés sont :
•.400 : Bad Request
•.401 : Unauthorized
•.403 : Forbidden
•.404 : Not found
•.405 : Method not allowed
Voir aussi le document rfc2616 qui définit les codes de statut : https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
Par exemple :
•.abort( 401 )
•.abort( Response( ’Cela se termine ici’ ) )
Une puissante fonctionnalité de Flask est l’opportunité d’utiliser le moteur de template Jinja pour faciliter la production de contenu.
Le moteur de template Jinja permet d’effectuer des remplacements à la volée dans des templates HTML (ou autres) stockés dans le sous-répertoire app/templates/ du projet. La fonction de traitement de la requête peut passer des données, des objets et des structures Python au template, ou ces éléments sont directement exploitables.
Les templates Jinja font l’objet d’un développement plus étendu dans la section Templates Jinja, l’exemple ci-dessous définit la route par défaut « / » en faisant appel au template demovar.html pour afficher le contenu des variables.
L’exemple est également disponible sur le dépôt GitHub du projet à l’emplacement suivant : /python/flask-demos/template-app/.
Le projet est constitué comme suit :
.
├── app
│ ├── __init__.py
│ ├── static
│ ├── templates
│ │ └── demovar.html
│ └── views.py
└── runapp.py
Le fichier __init__.py initialise le package app. Sans surprise, il charge le fichier views.py pour définir la prise en charge de différentes routes.
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask
# Initialisze l’application Flask
app = Flask( __name__ )
# Prise en charge des requêtes
from app import views
Le fichier views.py définit deux routes, la racine « / » et la racine avec un paramètre complémentaire « /<string:nom> » (un nom à afficher).
# coding: utf8
from app import app
from flask import render_template
@app.route(’/’)
@app.route(’/<string:nom>’)
def demovar( nom = None ):
items = [’banane’, ’orange’, ’pomme’, ’poire’]
return render_template( ’demovar.html’, name=nom,
elements=items )
La fonction demovar( nom=None ) peut être appelée avec un paramètre initialisé via la route « /<string:nom> » ou sans paramètre (et donc avec la valeur par défaut None) via la route « / ».
La fonction demovar(...) crée une liste d’éléments nommés items puis demande le rendu du template demovar.html qui sera retourné en réponse. Le template reçoit différents paramètres nommés ; l’un appelé name correspond à la variable locale nom, l’autre nommé elements correspond à la variable locale items. Le template pourra donc accéder à deux variables : name et elements. Ce petit jeu de passe-passe met en lumière la différence entre « variable de la fonction de traitement » et « variable de template » !
Le template demovar.html s’occupe du rendu HTML. Le fichier contient des balises HTML et des balises Jinja {% ... %} {{ ... }} interprétées par le moteur de rendu :
<!DOCTYPE HTML>
<html>
<head>
<title>Demo var</title>
</head>
<body>
<h1>Dit bonjour</h1>
{% if name %}
<p>Bonjour {{ name }}!</p>
{% else %}
<p>Bonjour à tous!</p>
{% endif %}
<h1>Fruits</h1>
<ul>
{% for el in elements %}
<li>{{ el }}</li>
{% endfor %}
</ul>
</body>
</html>
La balise {% if name %} permet d’afficher un contenu conditionnel si la variable name est initialisée, variable passée en paramètre lors de l’appel du template. La balise {% if ... %} est terminée par une balise {% endif %} et peut être utilisée conjointement avec un {% else %}.
Les balises {% for item in collection %} et {% endfor %} permettent d’énumérer le contenu d’une liste ou d’une collection et de répéter le contenu de la section pour chaque élément de la collection.
La balise {{ élément_a_évaluer }} permet d’évaluer un élément et d’en insérer le contenu dans le document de sortie.
Pour finir, le script runapp.py permet de démarrer facilement l’application Flask (avec python runapp.py) :
#!/usr/bin/env python
# coding: utf8
from app import app
app.run( debug=True, host=’0.0.0.0’, port=5000 )
# Production
#app.run( host=’0.0.0.0’ )
Voici ce que produit le template lorsqu’il n’y a pas de paramètre communiqué dans l’URL (http://192.168.1.210:5000/).
Génération d’une page à l’aide d’un template
Dans ce premier cas de figure, le paramètre nom = None lors de l’appel de demovar(), ce qui entraîne une valeur None pour la variable name dans le template (cf. appel de render_template).
Le second cas de figure passe un paramètre dans l’URL appelée (http://192.168.1.210:5000/Dominique). Dans ce cas, le paramètre nom = Dominique lors de l’appel de demovar(), cette valeur est transmise à la variable name du template. La variable name étant initialisée, la balise {% if name %} modifie le comportement du template pour afficher un message différent.
Modification du comportement du template
Le micro framework Flask inclut la fonction url_for très pratique pour créer des URL à partir des routes définies dans l’application.
Cela évite d’avoir à coder les URL en dur dans le script Python et dans les templates Jinja.
Les requêtes
La fonction url_for() prend le nom de la fonction de traitement en paramètre et génère l’URL associée. Les décorateurs @route() associant les fonctions de traitement aux différentes routes (URL), la fonction url_for() dispose donc de toutes les informations nécessaires à la résolution du nom de fonction vers URL.
Par exemple, si la route suivante est définie :
@app.route(’/demo’)
def montrer_demo():
return "Voici la page de démo".decode(’utf8’)
Alors il est possible de générer l’URL à partir de :
url_vers_demo = url_for( ’montrer_demo’ )
La fonction url_for() permet également de composer des URL avec paramètres.
Par exemple, si la route suivante est définie :
@app.route(’/bonjour/<nom>/<prenom>’)
def dit_bonjour( nom, prenom ):
return "Je dis bonjour a %s %s".decode(’utf8’) % (nom, prenom)
Alors il est possible de générer l’URL à partir de :
url_vers_bonjour = url_for( ’dis_bonjour’, prenom=’Antoine’, nom=’Couture’ )
Ressources statiques
La fonction url_for() permet également de produire des URL vers les fichiers statiques, ceux stockés dans le répertoire /app/static/ d’un projet Flask.
Dans un projet constitué comme suit :
.
├── app
│ ├── __init__.py
│ ├── static
│ │ ├── website.css
│ │ └── blog
│ │ └── girly.css
│ ├── templates
│ │ └── index.html
│ └── views.py
└── runapp.py
Le fichier website.css dans le répertoire static est accessible via l’URL composée à l’aide de :
url_for( ’static’, filename=’website.css’ )
Le fichier girly.css dans le répertoire static/blog est accessible via l’URL composée à l’aide de :
url_for( ’static’, filename=’blog/girly.css’ )
La redirection permet d’envoyer une réponse particulière au navigateur. Cette réponse indique au navigateur qu’il doit charger une autre page. Cela s’appelle une « redirection ».
La fonction redirect() est généralement utilisée conjointement avec url_for() pour rediriger le navigateur vers la liste des enregistrements après avoir inséré une nouvelle entrée dans l’application.
Le pseudo-code ci-dessous présente l’utilisation de la fonction redirect().
# coding: utf8
...
from flask import render_template, request, redirect
from flask import url_for, flash, abort, Response
@app.route(’/’)
def main():
...
return render_template( ’dash_list.html’, dash_list=rows )
@app.route(’/dashboard/add’, methods=[’GET’,’POST’] )
def dashboard_add():
if request.method == ’GET’:
# --- GET ---
...
return render_template( ’dash_edit.html’, row=row )
else:
# --- POST ---
...
if( request.form[’action’] == u’cancel’ ):
# Abandon
return redirect( url_for(’main’) )
app.logger.debug( ’saving dash %s’, data )
...
return redirect( url_for(’main’) )
# -- Second exemple --------------------------------------
@app.route(’/sessionvars’ )
def viewsession():
resp = ...
return resp
@app.route(’/sessionremove/<name>’)
def removesession( name ):
session.pop( name )
return redirect( url_for(’viewsession’) )
La fin du script avant le second exemple effectue la sauvegarde de l’enregistrement, puis retourne une réponse de redirection avec return redirect( url_for(’main’) ).
Lors d’une redirection, le serveur web renvoie l’URL de destination dans l’en-tête de la page HTML. Il n’y a donc pas de contenu HTML renvoyé au navigateur. La fonction redirect() utilise le statut HTML 302 (la ressource demandée réside temporairement à une URI différente).
Utilisation avancée
Le prototype de redirect est :
def redirect(location, code=302, Response=None)
•.Location : URL vers laquelle le navigateur doit être redirigé.
•.Code : permet de spécifier un code HTML associé à la redirection (302 par défaut). Flask supporte les codes 301, 302, 303, 305 et 307. Le code 300 n’est pas supporté, car il ne s’agit pas d’une réelle redirection. Le code 304 n’est pas supporté, car il concerne les requêtes contenant un champ If-Modified-Since dans l’en-tête.
•.Response : la majuscule stipule que le paramètre doit être une classe ! Cette classe est à utiliser pour envoyer la redirection. Il permet de stipuler une classe Response personnalisée autre que la classe par défaut utilisée par Flask (werkzeug.wrappers.Response).
Les codes HTML supportés par redirect sont :
•.301 : Moved Permanently - la ressource est assignée définitivement à une nouvelle URL.
•.302 : Found - la ressource réside temporairement à une nouvelle URL.
•.303 : See other - la réponse à cette requête est ailleurs.
•.305 : Use Proxy - indique que la requête doit être réalisé par l’intermédiaire d’un proxy (mentionné dans la réponse).
•.307 : Temporary redirect - indique que la ressource réside temporairement sur une autre URL.
Voir aussi le document rfc2616 qui définit les codes de statut : https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
Redirection et sécurité
Il est assez commun d’utiliser une redirection pour renvoyer l’utilisateur vers la page d’origine après une opération de login. Il y a deux approches pour réaliser ce type d’opération :
1. | Utiliser le referrer de la page au moment du rendu de la page de login. |
2. | Utiliser un champ NEXT indiquant la page suivante après une opération de login. |
Dans les deux cas, il faut s’assurer qu’il n’y a pas d’utilisation malveillante de la fenêtre de login et que le client n’est pas redirigé vers un site malveillant. Il est donc important de réaliser des vérifications complémentaires sur l’URL proposée avant d’opérer la redirection en fonction d’un champ (ou referrer HTML).
L’article « Securely Redirect Back » (http://flask.pocoo.org/snippets/62/) des Snippets Flask aborde ce sujet en détail.
Les routes définies dans l’application Flask acceptent, par défaut, les requêtes de type GET. Les requêtes GET permettent de collecter des informations depuis l’application Flask.
Lorsqu’une page web a besoin d’envoyer des informations vers l’application Flask, celle-ci utilise un formulaire HTML et une requête de type POST.
Flask supporte également les autres types de requêtes prévues par le W3C, à savoir DELETE, PUT, HEAD en plus de GET, POST.
Flask accepte les requêtes POST si cela est précisé dans le décorateur route.
@app.route(’/demo/<id>’, methods = [’GET’, ’POST’])
def demo(id):
if request.method == ’GET’:
# Générer la page pour la valeur "id"
...
return la_page_html
if request.method == ’POST’:
# Modifier les informations pour "id"
donnee = request.form # un dictionnaire avec les valeur
...
return la_page_de_confirmation
L’application suivante propose d’utiliser les requêtes de type GET et POST pour saisir un message, l’envoyer à l’application Flask (qui devrait l’envoyer sur un système de messagerie), puis produire une page de réponse.
Diagramme des échanges entre le navigateur et l’application Flask - réalisé avec draw.io
L’exemple flask-post.py est également disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/.
01: # coding: utf8
02: # Importer la bibliothèque Flask
03: from flask import Flask, Response, url_for, request
04:
05: # Initialise l’application Flask
06: app = Flask( __name__ )
07:
08: # Définir une route pour capturer la requête
09: # /message produire la réponse avec la fonction
10: # message()
11: @app.route(’/message’, methods=[’GET’, ’POST’] )
12: def message():
13: if request.method == ’GET’:
14: return Response( """<!DOCTYPE html>
15: <html>
16: <body>
17: <h1>Saisir un message</h1>
18: <form action="%s" method="post">
19: Destinataire:<br>
20: <input type="text" name="dest"><br>
21: Message:<br>
22: <input type="text" name="msg"><br>
23: <input type="submit" value="Envoyer">
24: </form>
25:
26: </body>
27: </html>""".decode(’utf8’) % url_for(’message’) )
28:
29: if request.method == ’POST’:
30: donnee = request.form
31: le_message = donnee[’msg’]
32: le_destinataire = donnee[’dest’]
33: # effectuer l’envoi
34: # ...
35:
36: # renvoyer la réponse
37: return Response( """<!DOCTYPE html>
38: <html>
39: <body>
40: <h1>Message envoyé</h1>
41: Le message "%s" à été envoyé à %s.
42: </body>
43: </html>""".decode(’utf8’) % (le_message, le_destinataire) )
44:
45: # Demarrer l’application sur le port 5000
46: app.run( debug=True, port=5000, host=’0.0.0.0’)
•.Lignes 1 à 6 : inclusion des éléments nécessaires et création de l’application Flask.
•.Ligne 11 : définition de la route /message pouvant accepter les requêtes de types POST et GET.
•.Ligne 13 : si le type de requête est de type GET, alors retourner une page HTML avec les champs de saisie.
•.Ligne 27 : transformation en chaîne unicode et inclusion de l’URL dans l’attribut action du tag <form>. La fonction url_for( ’message’ ) permet de produire l’URL correspondant à la fonction de traitement message().
•.Ligne 29 : si le type de requête est POST, alors le traitement est différent. Il faut récupérer les données, effectuer le traitement prévu et renvoyer une page de confirmation.
•.Ligne 30 : récupération des données du formulaire (balise <form> HTML). La variable donnee se présente alors comme un dictionnaire ou chaque valeur est stockée en utilisant l’identifiant du champ (voir attribut name). La syntaxe donnee[’msg’] permet de récupérer l’information encodée dans <input type="text" name="msg">.
•.Lignes 31 à 32 : récupération des différentes valeurs du formulaire.
•.Lignes 33 à 34 : traitement présupposé avec les données réceptionnées.
•.Lignes 37 à 43 : génération de la réponse renvoyée au navigateur après le traitement.
Une fois l’application Flask démarrée avec la commande python ./flask-post.py, il est possible de la tester en saisissant l’URL http://192.168.1.210/message. L’appel effectue une requête de type GET sur la route @app.route(’/message’, methods=[’GET’, ’POST’] ), ce qui produit la page de saisie du message.
Générer le formulaire HTML sur une requête de type GET.
Une fois les informations saisies dans la fenêtre et le bouton Envoyer pressé, l’URL http://192.168.1.210/message est rappelée avec, cette fois, une requête de type POST.
Données saisies dans le formulaire HTML
Ce qui produit le contenu suivant :
Confirmation produite suite à une requête de type POST
Bien que correcte sur le plan fonctionnel, la manipulation directe des champs de données et des balises form HTML ouvre la voie à de nombreuses tentatives de cracking de votre application.
Le micro framework Flask supporte également l’extension Flask-WTF. La bibliothèque contient WTForms qui permet de réaliser le rendu et la validation de formulaires HTML avec une grande flexibilité. Cette bibliothèque est très puissante et vaut la peine de s’y intéresser dès lors que l’application est accessible depuis Internet. Voir https://flask-wtf.readthedocs.io/en/stable/ pour plus d’informations.
Le contexte applicatif est destiné à maintenir des informations relatives à l’application durant l’exécution d’une requête. C’est donc un emplacement idéal pour stocker des données à partager avec tous les intervenants (views, template) du traitement d’une requête.
Lorsque Flask reçoit une requête, il crée un contexte applicatif puis un contexte de requête (qui stocke des informations relatives à la requête). La requête est traitée et une fois achevée, le contexte de requête est libéré puis le contexte applicatif. Le contexte applicatif a donc une durée de vie identique à celle du traitement de la requête.
Par ailleurs, lorsque Flask reçoit une requête via WSGI, celui-ci crée un fil d’exécution (thread, coroutine, etc.) pour traiter celle-ci. Le fil d’exécution est initialisé avec les informations de la requête, ce qui permet de mettre en place le contexte applicatif et le contexte de requête. Cela signifie donc que le contexte applicatif et contexte de requête sont tous deux isolés des autres requêtes gérées par Flask.
Le contexte applicatif expose un objet g permettant d’initialiser et de maintenir des informations durant tout le traitement d’une requête.
Sans surprise, le cas d’utilisation le plus fréquent est la création et le maintien d’une connexion vers une base de données, mais l’objet g permet de stocker bien d’autres informations.
La documentation Flask recommande l’utilisation du modèle suivant pour créer, utiliser et libérer les objets dans l’objet g :
•.Utiliser une fonction getter get_x() pour récupérer la référence souhaitée. Si l’objet ciblé n’a pas encore été créé, alors il faut le créer à la volée dans le getter.
•.Utiliser la libération du contexte applicatif ( @app.teardown_appcontext ) pour libérer l’objet ainsi créé.
accesseur vs getter : la fonction "getter" est plus communément connue sous le terme "accesseur" dans la littérature française. "getter" reste cependant un anglicisme très répandu chez les développeurs.
Le pseudo-code ci-dessous met en évidence les manipulations du contexte applicatif pour gérer une connexion vers une base de données.
From flask import g
# retrouver la base de données
def get_bdd() :
# Si objet de base de données pas encore créé
if ’bdd’ not in g :
# Créer l’objet de base de données et connecter la DB
g.bdd = connecter_bdd()
# retourner la reférence vers l’objet de base de données
return g.bdd
@app.teardown_appcontext
def teardown_app(exception):
# récupérer objet de base de données s’il existe sinon None
bdd = g.pop( ’bdd’, None )
# si objet existe alors fermer la connexion.
if bdd :
fermer_bdd( bdd )
# D’anciennes implémentations de Flask de disposent
# pas encore de g.pop(...), il faut alors procéder
# comme suit :
# bdd = g.get( ’bdd’, None )
# if dbb :
# fermer_bdd( bdd )
# del( bdd )
Grâce à la fonction get_bdd(), tous les appels obtiennent la même connexion vers la base de données. La fonction teardown_app() permet de clôturer la connexion sur la base de données si celle-ci est créée.
Les cookies permettent de stocker des informations dans le navigateur. Ces informations concernent le client et sa navigation sur le site et les cookies sont utilisés dans le but d’améliorer l’expérience utilisateur.
Les cookies stockent également le nom de domaine du site et une date d’expiration, les données stockées dans le cookie ne concernent que votre site.
Flask permet de manipuler les cookies à l’aide d’outils faciles d’emploi. L’initialisation et la modification des cookies sont effectuées lors du renvoi d’une réponse vers le client.
Initialiser un cookie
Pour initialiser un cookie, la fonction de traitement doit créer un objet Response à l’aide de la fonction make_response(). L’objet Response expose la méthode set_cookie() qui permet d’initialiser la valeur d’un cookie.
@app.route(’/login’, methods=[’POST’,’GET’] )
def do_login():
if request.method == ’GET’:
return render_template( ’login.html’)
if request.method == ’POST’:
user = request.form[’user_name’]
pswd = request.form[’user_pswd’]
user_id = user_login( user, pswd )
if not( user_id ):
return render_template( ’loginerror.html’ )
# Initialiser les cookies
rep = make_response( render_template(’main.html’) )
rep.set_cookie( ’userid’, user_id )
return rep
Récupérer un cookie
Les cookies sont communiqués par le navigateur lors de chaque requête. Ils sont donc disponibles sur l’objet request qui les expose à l’aide de la propriété cookies.
@app.route(’/userinfo’ )
def show_user_info():
# récupération de la valeur du cookie
user_id = request.cookies.get( ’userid’ )
if not( user_id ):
return render_template( ’error.html’)
else:
user_obj = get_user_info( user_id )
return render_template( ’userinfo.html’, user=user_obj )
Redirection et cookie
Les cookies sont assignés sur l’objet Response contenant la page HTML à renvoyer. Or, la fonction redirect() ne crée pas d’objet Response ! A priori, il n’est pas possible de modifier la valeur des cookies lorsque l’on effectue une redirection à moins d’utiliser un tour de passe-passe.
from flask import make_response, redirect, url_for
if la_condition :
response = make_response( redirect(url_for(’main’)) )
response.set_cookie( ’nom_parametre’,
la_valeur_dans_le_cookie )
return response
Exemple
L’exemple flask-cookie.py disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/ permet de tester et de manipuler les cookies.
Flask prend en charge un support de session qui, en opposition avec les cookies, permet de stocker des informations côté serveur.
La session est généralement utilisée pour stocker des informations sur l’utilisateur entre la connexion (login) et la déconnexion (logout) de celui-ci sur l’application Flask.
Même si cela n’est pas directement visible, la session s’appuie sur la gestion des cookies pour identifier la session. En effet, un identifiant de session (session ID) est encrypté et stocké dans les cookies de l’utilisateur. Grâce à lui, Flask est capable d’identifier la session lors de chaque requête et peut recharger les éléments de la session côté serveur.
L’utilisation d’une session requiert donc la définition d’une clé secrète (SECRET_KEY) dans la configuration de l’application Flask.
app = Flask( __name__ )
app.config[’SECRET_KEY’] = ’la-cle-secrete’
Les informations de session sont manipulées aussi simplement qu’un dictionnaire Python.
from flask import session
# assignation
session[’user_id’] = id_utilisateur
# extraction
id = session[’user_id’]
# Tester la présence
if ’user_id’ in session:
id = session[’user_id’]
Une variable de session peut être enlevée à l’aide de méthode pop(var_name).
session.pop(’user_id’)
Exemple
L’exemple flask-session.py disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/ permet de tester et de manipuler les variables de session.
Stockage des sessions
Par défaut, les sessions sont gérées par le processus Flask, ce qui signifie qu’elles sont réinitialisées à chaque redémarrage de l’application Flask.
L’implémentation du stockage des sessions est néanmoins réalisée par l’intermédiaire d’une interface. Il est donc possible d’altérer la façon dont Flask stocke les données de session pour, par exemple, les stocker dans une base de données ou un serveur Redis.
L’extension Flask-Session permet de stocker les sessions sur différents types de supports tels que :
•.Redis : https://redis.io/
•.Memcached : https://memcached.org/
•.filesystem : dans le système de fichiers
•.MongoDB : https://www.mongodb.com/
•.SQLAlchemy : https://www.sqlalchemy.org/
Voyez la page https://pythonhosted.org/Flask-Session/ pour plus d’informations.
Flask propose un logger sur l’application.
# coding: utf8
# Importer la bibliothéque Flask
from flask import Flask
# Initialise l’application Flask
app = Flask( __name__ )
app.logger.info(’Flask app créer. Message d info.’)
app.logger.error(’Message d erreur.’)
Le micro framework Flask propose une configuration par défaut envoyant tous les messages d’erreurs vers la sortie d’erreur standard (sys.stderr) tel que défini dans la variable d’environnement environ[’wsgi.errors’]. À noter que la configuration par défaut permet uniquement de collecter les messages d’erreur vers la console.
Le logger est initialisé lors du premier accès à la propriété app.logger. Par conséquent, toute modification de la configuration du logger doit intervenir, autant que possible, avant la création de l’objet app.
Loguer toutes les informations
La configuration par défaut ne capture que les messages d’erreurs. Il est pourtant intéressant de pouvoir capturer les autres types de messages (info, debug, warning).
L’exemple ci-dessous propose un logger alternatif nommé « app » (et son formatter associé) permettant de distinguer les messages de l’application des messages du logger racine « root ».
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask
from logging.config import dictConfig
from logging import getLogger
import sys
# ======================================
# Modifier la configuration du Logger
# ======================================
# Lorsqu’exécuté depuis la console avec Werzeug, les handlers
# stdout et wsgi affichent l’information sur la console
dictConfig({
’version’: 1,
’formatters’: {
’default’: {
’format’: ’[%(asctime)s] x %(levelname)s in %(module)s: %(message)s’,
},
’appformatter’ : {
’format’: ’[%(asctime)s] -(APP)- %(levelname)s in %
(module)s: %(message)s’,
}
},
’handlers’: {
’wsgi’: {
’class’: ’logging.StreamHandler’,
’stream’: sys.stdout,
’formatter’: ’default’
},
’stdout’: {
’class’: ’logging.StreamHandler’,
’stream’: sys.stdout,
’formatter’: ’appformatter’
}
},
’loggers’ : {
’root’: {
’level’: ’DEBUG’,
’handlers’: [’wsgi’]
},
’app’: {
’level’: ’DEBUG’,
’handlers’: [’stdout’]
}
}
})
# Initialise l’application Flask
app = Flask( __name__ )
# Utiliser le logger racine « root »
app.logger.info( ’Log on root logger!’ )
# Utiliser le logger « app »
getLogger(’app’).info( ’Log on APP logger!’ )
index_template = """<!DOCTYPE html>
<html>
<body>
<h1>Logger demo</h1>
Un message d’erreur vient d’être envoyé vers le logger.
</body>
</html>""".decode(’utf8’)
@app.route(’/’ )
def index():
getLogger(’app’).error( ’Appel de /’ )
resp = app.jinja_env.from_string(index_template).render()
return resp
# Demarrer l’application sur le port 5000
app.run( debug=True, port=5000, host=’0.0.0.0’)
Approche pythonique
En utilisant une approche pythonique, il est tout à fait possible de définir un second logger sur l’objet applicatif. Cela permet d’avoir à disposition ce deuxième logger sans avoir besoin d’appeler systématiquement logging.getLogger( ’app’ ).
Le script ci-dessus devient donc :
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask
from logging.config import dictConfig
from logging import getLogger
import sys
# ======================================
# Modifier la configuration du Logger
# ======================================
# Lorsqu’exécuté depuis la console avec Werzeug, les handlers
# stdout et wsgi affichent l’information sur la console
dictConfig({
...
})
# Initialise l’application Flask
app = Flask( __name__ )
app.logger.info( ’Log on root logger!’ )
app.applogger = getLogger(’app’)
app.applogger.info( ’Log on APP logger!’ )
index_template = """<!DOCTYPE html> ... """.decode(’utf8’)
@app.route(’/’ )
def index():
app.applogger.error( ’Appel de /’ )
return app.jinja_env.from_string(index_template).render()
# Demarrer l’application sur le port 5000
app.run( debug=True, port=5000, host=’0.0.0.0’)
Bien que cela soit tentant d’ajouter de nombreuses propriétés sur app, cela n’est pas recommandé dans les directives de développement Flask.
Logging complémentaire
Certains paquets effectuent des opérations de journalisation de façon intensive. C’est le cas, par exemple, de SQLAlchemy. Il est possible d’alimenter le journal avec une configuration personnalisée de dictConfig pour capturer les messages en ajoutant les instructions suivantes au script :
from flask.logging import default_handler
logging.getLogger(’sqlalchemy’).addHandler(default_handler)
Il est donc opportun de consulter la documentation des extensions Flask, car ces dernières fournissent des informations sur la journalisation pouvant se montrer extrêmement utiles.
Autres exemples
Les exemples flask-logger.py, flask-logger-all.py, flask-logger-all2.py disponibles sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/ permettent de tester et manipuler les différentes options pour journaliser des messages.
Ce mini-projet démontre la mise en œuvre d’une base de données SQLite 3 dans un projet Flask.
Pour que la démonstration soit satisfaisante, l’exemple va au-delà de la simple connexion SQLite en proposant quelques fonctionnalités de bases comme lister le contenu d’une table de fruits et proposer la modification d’enregistrements. Il mettra en œuvre une connexion SQLite 3, les routes, l’édition de formulaire HTML (GET et POST), des templates Jinja.
Le but du mini-projet est d’offrir un maximum de fonctionnalités avec un minimum de complexité.
À l’exception des feuilles de style (CSS) et de WTF (saisie et validation de formulaire HTML), cet exemple est ce qui se rapproche le plus d’une vraie application Flask.
Résultat de l’URL racine de l’exemple sqlite-app
Édition d’un enregistrement (exemple sqlite-app)
Explorer les entrailles de ce mini-projet est un excellent point de départ pour comprendre et constituer un autre projet Flask autour d’une base de données.
L’exemple fruits-app est disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/fruits-app.
Il utilise la base de données SQLite 3 food.db stockée dans le répertoire principal à côté du script runapp.py.
Pour lancer facilement l’application, démarrer un terminal dans le répertoire /python/flask-demos/fruits-app, puis saisir python runapp.py pour démarrer l’application Flask.
Les connexions SQLite ont déjà été abordées précédemment (cf. Persistance des données - SQLite3) et les exemples SQLite abordés dans ce chapitre seraient tout aussi valables.
Il existe cependant une meilleure approche que celle consistant à créer une connexion de base de données dans chaque fonction de traitement (au moment où celle-ci est nécessaire).
Flask expose un contexte applicatif et la variable g permettant de stocker divers éléments facilement accessibles dans le code Python ainsi que dans les templates Jinja. Il est recommandé d’utiliser le contexte applicatif et un getter pour obtenir la connexion à la base de données. En effet, cela permet de restreindre la connexion à une seule et unique instance pour exécuter tous les accès en base de données nécessaires pour le traitement de la requête.
Pour rappel, voici l’approche recommandée dans la section « Contexte applicatif » :
From flask import g
# retrouver la base de données
def get_bdd() :
if ’bdd’ not in g :
g.bdd = connecter_bdd()
return g.bdd
@app.teardown_appcontext
def teardown_app(exception):
bdd = g.pop( ’bdd’, None )
if bdd :
fermer_bdd( bdd )
# D’anciennes implémentations de Flask ne disposent
# pas encore de g.pop(...), il faut alors procéder
# comme suit :
# bdd = g.get( ’bdd’, None )
# if dbb :
# fermer_bdd( bdd )
# del( bdd )
Dans l’exemple ci-dessous, l’application Python accède à une base de données SQLite 3 pour afficher le contenu de la table fruits de la base de données food.db. Il est également possible d’ajouter et d’effacer des enregistrements dans la table.
Le projet est constitué des éléments suivants :
.
├── app
│ ├── __init__.py
│ ├── config.py
│ ├── views.py
│ ├── models.py
│ ├── static
│ └── templates
│ ├── fruit_edit.html
│ └── fruit_list.html
├── food.db
└── runapp.py
•.Fichier __init__.py : initialisation du paquet app et chargement des vues (views.py) et du modèle de base de données (models.py).
•.Fichier config.py : configuration de la journalisation. Capture de tous les niveaux de messages.
•.Fichier views.py : définition des routes de l’application et production des pages.
•.Fichier models.py : fonctions d’accès à la base de données et collecte d’informations (la liste des fruits, collecte d’un enregistrement fruit, sauvegarde des modifications, etc.).
•.Répertoire static : contient les données statiques comme les images et la feuille de style CSS.
•.Répertoire templates : contient les templates Jinja utilisés par l’application. Le fichier fruit_list.html est utilisé pour produire la liste des fruits sous forme d’une table HTML. Le fichier fruit_edit.html est utilisé pour éditer ou ajouter un enregistrement.
•.Fichier food.db : la base de données précédemment initialisée (cf. Persistance des données - SQLite 3).
•.Fichier runapp.py : activation de l’application Flask.
Le fichier __init__.py
Ce script d’initialisation du paquet effectue les opérations suivantes :
1. | Modification de la configuration de journalisation pour capturer les messages de débogage. |
2. | Chargement du fichier views.py pour l’activation des routes. |
3. | Chargement du fichier models.py pour l’enregistrement de la fonction teardown_app(). |
# coding: utf8
# Importer la bibliothèque Flask
from flask import Flask
from app.config import configuration
from logging.config import dictConfig
# Nouvelle config pour capturer
# tous les niveaux de log
dictConfig( configuration )
# Initialisze l’application Flask
app = Flask( __name__ )
# Prise en charge des requêtes
from app import views
from app import models
Le fichier models.py
Le script models.py prend en charge toutes les opérations relatives à la base de données.
Il définit le getter de la base de données, get_bdd(), et la fonction teardown_app() qui libère la connexion.
Viennent ensuite les fonctions utilitaires get_fruits(), get_fruit(), insert_fruit(), update_fruit() et drop_fruit().
01: # coding: utf8
02: from app import app
03: from flask import g
04: import sqlite3
05: # ----------------------------------------
06: # Accès à la base de données
07: # ----------------------------------------
08: def get_bdd():
09: if not ’bdd’ in g:
10: g.bdd = sqlite3.connect( ’food.db’ )
11: return g.bdd
12:
13: @app.teardown_appcontext
14: def teardown_app(exception):
15: # Version plus récente de Flask
16: #
17: #_bdd = g.pop( ’bdd’, None )
18: #if( _bdd):
19: # _bdd.close()
20:
21: _bdd = g.get( ’bdd’, None )
22: if( _bdd):
23: _bdd.close()
24: del( _bdd )
25:
26: # ----------------------------------------
27: # Utilitaires
28: # ----------------------------------------
29:
30: def get_fruits():
31: """ Obtenir la liste des fruits (id, nom, kcal_100gr)
32: """
33: cursor = get_bdd().cursor()
34:
35: cursor.execute( "select id, name, kcal_100gr from
36: fruits order by name")
37: if cursor.rowcount == 0:
38: return None
39: else:
40: # NB: Liste les noms des colonnes
41: # colnames = [item[0] for item in
42: # cursor.description ]
43:
44: # retourne une liste de tuples
45: return cursor.fetchall()
46:
47: def get_fruit( id ):
48: """ obtenir les informations d’un fruit donné """
49: assert type(id)==int
50:
51: cursor = get_bdd().cursor()
52:
53: cursor.execute( "select id, name, kcal_100gr from
54: fruits where id = %s" % id )
55: if cursor.rowcount == 0:
56: return None
57: return cursor.fetchone()
58:
59: def insert_fruit( name, kcal ):
60: assert type(kcal)==int
61:
62: cursor = get_bdd().cursor()
63:
64: cursor.execute(
65: "insert into fruits (name,kcal_100gr) values (?,
66: ?)",
67: (name, kcal)
68: )
69: if cursor.rowcount > 0:
70: rowid = cursor.lastrowid
71: else:
72: rowid = None
73:
74: get_bdd().commit()
75:
76: return None
77:
78: def update_fruit( id, name, kcal ):
79: assert type(id)== int
80: assert type(kcal)==int
81:
82: cursor = get_bdd().cursor()
83:
84: cursor.execute(
85: "update fruits set name = ?, kcal_100gr = ? where
86: id = ?",
87: (name, kcal, id)
88: )
89:
90: get_bdd().commit()
91:
92: return cursor.rowcount > 0
93:
94: def drop_fruit(id):
95: assert type(id)==int
96:
97: cursor = get_bdd().cursor()
98:
99: cursor.execute(
100: "delete from fruits where id = ?",
101: (id,) # tuple necessaire -> virgule
102: )
103:
104: get_bdd().commit()
105:
106: return cursor.rowcount
•.Lignes 8 à 11 : définition du getter de base de données. Il crée la connexion SQLite3 si celle-ci n’est pas présente dans le contexte applicatif (g). Si la connexion existe déjà alors cette dernière est retournée directement depuis le contexte applicatif.
•.Ligne 13 : décorateur permettant d’appeler la fonction teardown_app() lorsque le contexte applicatif est détruit (donc après le traitement de la requête).
•.Lignes 17 à 19 : ces lignes en commentaire concernent la version récente de Flask exposant la méthode pop(). Les versions antérieures doivent procéder autrement (cf. lignes 21 à 24).
•.Lignes 21 à 24 : certaines URL pouvant s’exécuter sans besoin d’une connexion à la base de données, le getter pourrait très bien ne pas avoir été appelé. Il faut donc vérifier l’éventuelle présence de l’objet dans le contexte applicatif avant d’essayer de le détruire. Si l’objet connexion à la base de données est retrouvé dans le contexte applicatif, alors il faut fermer la connexion à la base de données puis détruire l’objet.
•.Ligne 30 : définition de get_fruits() qui retourne une collection d’enregistrements (une liste de tuples) à l’aide d’une requête SQL. Les détails concernant les manipulations de SQLite3 en ligne de commande et avec Python ont été abordés en détail dans le chapitre ESP8266 sous MicroPython (cf. Persistance des données - SQLite 3).
•.Ligne 33 : obtention d’une connexion vers la base de données avec le getter get_bdd() et création d’un curseur sur cette connexion.
•.Lignes 37 à 45 : obtention de la collection d’enregistrements ou None s’il n’y a pas de donnée.
Une autre technique consiste à retourner une liste vide [ ] s’il n’y a pas de donnée, ce qui évite au code appelant de devoir faire un test sur None avant d’énumérer le contenu.
•.Ligne 47 : définition de get_fruit( id ) pour obtenir des informations sur un fruit identifié par son id. Cette fonction retourne un tuple de données ou None.
•.Ligne 49 : utilisation d’une assertion pour vérifier le type du paramètre.
•.Lignes 51 à 57 : obtention de l’information à l’aide d’une requête SQL. À noter l’utilisation de fetchone() en ligne 57 pour obtenir un seul enregistrement !
•.Ligne 59 : définition de insert_fruit( name, kcal ) pour insérer un enregistrement dans la base de données à l’aide d’une requête SQL de type INSERT.
•.Lignes 60 à 68 : utilisation d’une assertion pour contrôler le type de kcal. À noter qu’il n’y a pas d’opération de décodage sur name, car il s’agit déjà d’une chaîne de caractères unicode ( type(name) = unicode ).
•.Lignes 69 à 72 : récupération du rowid du dernier enregistrement, correspond à la colonne id (cf. Persistance des données - SQLite 3).
•.Ligne 74 : appel de la méthode commit() sur la connexion pour enregistrer les données.
•.Ligne 76 : renvoi de l’identification de l’enregistrement (correspondant au champ id).
•.Lignes 78 à 92 : définition de la fonction update( id, name, kcal ) permettant de modifier un enregistrement. Rien de particulier à signaler si ce n’est un état booléen True/False pour indiquer la réussite de l’opération.
•.Lignes 94 à 106 : définition de la fonction drop_fruit( id ) retournant le nombre d’enregistrements effacés (idéalement 1 enregistrement). À noter que cursor.execute() requiert impérativement un tuple en paramètre, raison pour laquelle l’id est passé avec la syntaxe (id,) afin de provoquer la création d’un tuple.
Le fichier views.py
Ce script contient la définition des différentes routes pour la prise en charge des requêtes. Les vues (views.py) s’appuient sur le modèle (models.py) pour collecter les données depuis la base de données. Les données récupérées depuis le modèle sont ensuite utilisées avec des templates Jinja pour produire le contenu HTML.
01: # coding: utf8
02: from app import app
03: from app.models import get_fruits, get_fruit, insert_fruit,
04: update_fruit, drop_fruit
05: from flask import render_template, request, abort,
06: redirect, url_for
07:
08: def safe_cast( value, totype, default=None):
09: try:
10: return totype( value )
11: except Exception, e:
12: return default
13:
14: @app.route(’/’)
15: def fruit_list():
16: fruits = get_fruits()
17: app.logger.debug( ’get_fruits(): %s’, fruits )
18: return render_template( ’fruit_list.html’, rows=fruits )
19:
20: @app.route(’/fruit-edit/<int:id>’, methods=[’GET’,’POST’] )
21: @app.route(’/fruit-edit/new’, methods=[’GET’,’POST’] )
22: def fruit_edit( id=-1 ):
23: """ Edition et sauvegarde d’un fruit """
24: if request.method == ’GET’:
25: if id == -1: # New record ?
26: nom = ’Nouveau’
27: kcal = 0
28: title = ’Nouveau fruit’
29: else:
30: fruit = get_fruit( id )
31: if not(fruit):
32: raise Exception( ’Impossible de charger id
33: %s’ % id )
34:
35: nom = fruit[1]
36: kcal = fruit[2]
37: title = ’Modifier %s’ % nom
38: return render_template( ’fruit_edit.html’, id=id,
39: nom=nom, kcal=kcal, title=title )
40:
41: else: # C’est un POST
42: # recupérer les paramètres
43: nom = request.form[’nom’]
44: kcal = safe_cast( request.form[’kcal’], int, 0 )
45: id = safe_cast( request.form[’id’], int, None )
46: if id == None:
47: raise Exception( ’Malformed ID dans le POST!’)
48:
49: if id == -1: # Nouvel enregistrement ?
50: rowid = insert_fruit( nom, kcal )
51: app.logger.debug( ’Insertion fruit à id=%s’,
52: rowid)
53: else: # C’est une modification (update)
54: update_fruit( id, nom, kcal )
55: return redirect( url_for(’fruit_list’) )
56:
57:
58: @app.route(’/fruit-delete/<int:id>’)
59: def fruit_delete( id ):
60: """ Effacer un enregistrement """
61: count = drop_fruit( safe_cast(id, int) )
62: app.logger.info( ’Effacer fruit %s pour id=%s’,
63: count, id )
64: return redirect( url_for(’fruit_list’) )
•.Ligne 3 : import des fonctions du modèle permettant d’accéder aux données. Une instruction « from app.models import * » conviendrait parfaitement, mais d’un point de vue didactique, il est utile d’identifier l’origine des différentes fonctions.
•.Ligne 8 : définition de la fonction safe_cast( value, totype, default=None ) permettant de transformer une valeur vers un type donné en toute sécurité. Si la transformation échoue, la fonction safe_cast retourne une valeur par défaut configurable en troisième paramètre (None par défaut). Cette fonction permet de transformer une donnée provenant d’un formulaire HTML en toute sécurité.
•.Lignes 14 à 15 : définition de la route racine et prise en charge par la fonction fruit_list() qui affiche la liste des fruits.
Liste des fruits. Produit par la route « / »
•.Ligne 16 : obtention de la liste des fruits. Retourne une réponse None ou une liste de tuples (id,nom,kcal_100gr).
•.Ligne 17 : utilisation du logger pour envoyer un message de débogage.
•.Ligne 18 : effectuer un rendu à l’aide du template fruit_list.html en passant la liste des fruits comme paramètre rows (rows en anglais signifie « lignes »). Les détails concernant le template fruit_list.html sont abordés plus loin dans cette section.
•.Lignes 20 et 22 : définition de la route /fruit-edit/<int:id> et prise en charge par la fonction fruit_edit( id ) qui permet d’éditer une entrée de la table fruits. Le paramètre id est l’identifiant de l’enregistrement dans la table. L’appel produit le résultat suivant :
Édition d’une entrée existante
•.Lignes 21 et 22 : définition de la route /fruit-edit/new (sans paramètre) et prise en charge par la fonction fruit_edit( id=-1 ) avec la valeur par défaut -1 qui, dans ce cas, indique la création d’un nouvel enregistrement. L’appel produit le résultat suivant :
Insertion d’une nouvelle entrée
Bien que la fonction fruit_edit() utilise id=-1 pour détecter la création d’un nouvel enregistrement, il n’est pas possible d’appeler l’URL /fruit-edit/-1 car -1 n’est pas reconnu comme un nombre entier ! En conséquence, l’URL /fruit-edit/-1 ne peut pas être prise en charge par une route.
•.Lignes 24 à 39 : s’il s’agit d’une opération GET, alors il faut construire un rendu de la fenêtre HTML. Deux cas se présentent : si id ≥ 0, alors il faut recharger les informations du fruit pour modification. Si id = -1, alors il faut initialiser les informations du fruit avec des valeurs par défaut pour l’encodage d’un nouvel enregistrement.
•.Ligne 31 : si l’information sur le fruit n’a pas pu être rechargée (parce que l’id serait incorrect), alors on provoque la levée d’une Exception. En mode développement, cela active le débogueur Flask, sinon cela produira une erreur 500 (Internal Server Error).
•.Ligne 39 : effectuer un rendu à l’aide du template fruit_edit.html en passant les paramètres id, nom, kcal et title (titre de la page).
•.Lignes 41 à 55 : s’il s’agit d’une opération POST, alors il s’agit d’une sauvegarde du formulaire HTML (produit par le GET). Il faut donc récupérer les valeurs depuis celui-ci, puis sauver les modifications (ou insérer un nouvel enregistrement si id = -1).
•.Lignes 43 à 45 : récupération des différentes valeurs depuis le formulaire. La valeur kcal est convertie en entier (avec 0 comme valeur par défaut). L’id est également converti en entier (avec la valeur None comme valeur par défaut).
•.Lignes 46 à 47 : si l’id est None, alors cela signifie qu’il n’a pas pu être converti en entier et par conséquent il est préférable d’arrêter le traitement à l’aide d’une exception.
•.Lignes 49 à 54 : si l’id mentionné est -1, alors il s’agit de l’insertion d’un nouvel enregistrement (provoquant l’appel de insert_fruit() sur le modèle models.py). Sinon l’id est >=0, ce qui signifie qu’il s’agit de la mise à jour d’un enregistrement existant (provoquant l’appel de update_fruit() sur le modèle models.py).
•.Ligne 55 : effectuer une redirection vers la liste des fruits, donc l’URL correspondant à la fonction fruit_list().
•.Lignes 58 à 64 : prise en charge de l’effacement d’un enregistrement. La fonction fait appel à la fonction drop_fruits() du modèle suivi d’une redirection vers la liste des fruits.
Séquence de la modification
Le diagramme suivant résume la séquence des appels lors de la modification d’un fruit existant.
Séquence d’appel lors de la modification d’un enregistrement (réalisé avec draw.io)
Séquence d’un ajout
Le diagramme suivant résume la séquence des appels lors de l’ajout d’un nouveau fruit.
Cette séquence est un cas particulier de la séquence de modification. Le comportement général est en effet identique avec quelques petites variantes.
Séquence d’appel lors de l’ajout d’un enregistrement (réalisé avec draw.io)
Template fruit_list.html
Ce template Jinja est appelé par la fonction de traitement fruit_list() du fichier views.py.
Le template est appelé avec le paramètre rows correspondant à une liste de tuples, à savoir [ (id, nom, kcal_100gr), ... ].
Il permet de produire le contenu suivant :
Liste des fruits produite avec le template fruit-list.html
Le template contient :
01: <!DOCTYPE HTML>
02: <html>
03: <head>
04: <title>Fruit list</title>
05: </head>
06: <body>
07: <h1>Fruits</h1>
08: <table border="1">
09: <tr>
10: <th>Nom</th>
11: <th>KCal / 100gr</th>
12: <th>Options</th>
13: </tr>
14: {% for el in rows %}
15: <tr>
16: <td>{{ el[1] }}</td>
17: <td align="right">{{ el[2] }}</td>
18: <td><small>
19: <a href="{{ url_for(’fruit_edit’,
20: id=el[0]) }}">Editer</a>
21: <a href="{{ url_for(’fruit_delete’,
22: id=el[0]) }}">Effacer</a>
23: </small></td>
24: </tr>
25: {% endfor %}
26: <tr><td align="right" colspan="3">
27: <small>
28: <a href="{{ url_for(’fruit_edit’)
29: }}">Nouveau</a>
30: </small></td></tr>
31: </table>
32: </body>
33: </html>
•.Lignes 1 à 13 : début du fichier HTML et mise en place de la table HTML avec la ligne de titre (3 colonnes).
•.Ligne 14 : début d’une boucle for itérant tous les éléments contenus dans rows (la liste de tuples). Toutes les lignes entre {% for el in rows %} et la marque {% endfor %} (en ligne 25) seront répétées pour chacun des éléments de la liste. Chaque élément de la liste (donc un tuple (id, nom, kcal_100gr) ) sera associé tour à tour à la variable el.
•.Ligne 15 : ouverture d’une ligne dans la table <tr>, ligne fermée avec </tr> en ligne 24.
•.Ligne 16 : création d’une cellule <td> et inclusion du nom du fruit (deuxième élément du tuple) avec {{ el[1] }}.
•.Ligne 17 : création d’une cellule <td> et inclusion du nombre de calories (troisième élément du tuple) avec {{ el[2] }}.
•.Ligne 18 : création d’une nouvelle cellule <td> pour inclure les liens « Editer » et « Effacer ».
•.Ligne 19 : création d’un lien <a> vers l’URL d’édition. La fonction url_for() est utilisée pour produire l’URL gérée par la fonction fruit_edit( id=-1 ) (dans views.py). Étant donné que la route @app.route(’/fruit-edit/<int:id>’) mentionne une route avec un paramètre id, ce dernier doit être fourni lors de l’appel à url_for(). Au final, l’appel est {{ url_for(’fruit_edit’, id=el[0]) }} puisque l’id est dans le premier élément du tuple.
•.Ligne 21 : inclusion d’un lien <a> vers l’URL d’effacement. Le principe est identique à celui de la ligne 19, à l’exception que la destination de l’URL est la fonction fruit_delete().
•.Ligne 26 : ajout d’une ligne <tr> en bas du tableau pour ajouter le lien <a> « Nouveau » permettant la création d’une nouvelle entrée dans la table fruits. Cette fois, url_for(’fruit_edit’) est appelé sans paramètre ! Étant donné que la fonction fruit_edit( id=-1 ) supporte une deuxième route @app.route( ’/fruit-edit/new’ ) sans paramètre, cela produira l’URL /fruit-edit/new qui, elle, débouchera sur l’appel de la fonction fruit_edit( id=-1 ) avec la valeur par défaut -1.
Comme cela a été détaillé dans les explications des vues (views.py), il n’est pas possible d’appeler l’URL /fruit-edit/-1 avec un id négatif.
Template fruit_edit.html
Ce template est appelé par la fonction de traitement fruit_edit() du fichier views.py.
Le template est appelé avec les paramètres :
•.title : titre de la page (soit « Modifier nom-du-fruit », soit « Nouveau Fruit »).
•.id : identifiant de l’enregistrement contenant une valeur ≥ 0 pour une modification ou -1 pour une création d’enregistrement.
•.nom : nom du fruit à modifier (contient « Nouveau » lors d’un ajout).
•.kcal : valeur numérique, nombre de kcal par 100 g (contient 0 lors d’un ajout).
Le template permet de produire les contenus suivants :
Modification d’un enregistrement (id ≥ 0)
Ajout d’un nouvel enregistrement (id = -1)
Le template contient :
01: <!DOCTYPE HTML>
02: <html>
03: <head>
04: <title>{{ title }}</title>
05: </head>
06: <body>
07: <h1>{{ title }}</h1>
08: <form
09: action="{{ url_for(’fruit_edit’,id=id if id>=0 else None) }}"
10: method="post">
11: <table border="1">
12: <tr>
13: <td>Nom</td>
14: <td><input type="text" name="nom"
15: value="{{ nom }}"></td>
16: </tr>
17: <tr>
18: <td>KCal / 100gr</td>
19: <td><input type="text" name="kcal"
20: value="{{ kcal }}"></td>
21: </tr>
22:
23: <tr><td align="right" colspan="2">
24: <input type="hidden" name="id"
25: value="{{ id }}">
26: <input type="submit" value="Envoyer" />
27: <input type="button"
28: onclick="location.href=’{{ url_for(’fruit_list’) }}’;"
29: value="Abandonner" />
30:
31: </td></tr>
32: </table>
33: </form>
34: </body>
35: </html>
•.Lignes 1 à 6 : début du fichier HTML jusqu’au corps HTML <body>.
•.Ligne 7 : inclusion de la variable titre dans une balise <h1> avec {{ titre }}.
•.Ligne 8 : création d’un élément <form> HTML de type POST (voir ligne 10). Le formulaire HTML doit être posté sur l’URL correspondant à la fonction fruit_edit( id = -1 ) avec l’évaluation de {{ url_for(’fruit_edit’,id=id if id>=0 else None) }}.
Il faut prendre en charge l’appel avec un id ≥ 0 vers l’URL /fruit-edit/id tout comme l’appel avec id = -1 débouchant vers l’URL /fruit-edit/new (lorsque l’id est omis lors de l’appel d’url_for() ). Cette particularité est prise en charge par l’expression ternaire id if id>=0 else None qui retourne l’id si celui-ci est supérieur ou égal à zéro, sinon l’expression retournera None (ce qui sera le cas si id égale -1). Lors de l’appel d’url_for(), le paramètre id= communiqué recevra donc une valeur numérique ou None selon les circonstances.
•.Ligne 14 : champ texte pour la saisie du nom <input type="text" name="nom" value="{{ nom }}" > dont la valeur est initialisée à l’aide de {{ nom }}, donc le contenu de la variable nom communiquée au template. La valeur modifiée par l’utilisateur sera ensuite communiquée dans la requête POST sous l’identifiant « nom » correspondant à la zone de saisie (cfr. l’attribut name="nom" ).
•.Ligne 19 : champ texte pour la saisie de la valeur KCalorie. La valeur de la variable kcal communiquée au template est insérée à l’aide de {{ kcal }}. La validation de la valeur est déléguée à l’application Flask (au moment de la capture du POST).
L’extension Flask-WTF déjà évoquée plusieurs fois dans l’ouvrage permet de réaliser un contrôle de saisie et une validation côté client avant l’envoi du formulaire.
•.Lignes 24 à 25 : champ masqué transportant l’information id. La valeur du champ est initialisée avec la variable id communiquée au template (cf. {{ id }} ).
•.Ligne 26 : bouton submit utilisé pour envoyer le formulaire HTML vers le serveur Flask.
•.Lignes 27 à 29 : ajout d’un bouton Abandonner. La pression sur ce bouton active un code JavaScript redirigeant le navigateur vers l’URL avec location.href=’http://xxxx’;. L’URL de destination est produite avec la fonction url_for() et injectée dans le code JavaScript à l’aide de {{ url_for(’fruit_list’) }}. Sans surprise, le navigateur est renvoyé vers la liste des fruits.
Il existe de nombreuses informations sur Flask :
•.Documentation Flask : documentation très complète de Flask, http://flask.pocoo.org/docs/.
•.Flask Snippets : les snippets sont des cas d’utilisation pratique mis en œuvre avec le framework Flask. Les bouts de code dans les snippets abordant de nombreux domaines comme les API, la structure d’application, les décorateurs, le déploiement, la sécurité, la performance, l’authentification, etc. http://flask.pocoo.org/snippets/.
•.Extensions Flask : liste des extensions Flask, http://flask.pocoo.org/extensions/.
•.Communauté Flask : liste de liens permettant de trouver des informations et de l’aide sur Flask, http://flask.pocoo.org/community/.
•.Flask-WTF : http://flask-wtf.readthedocs.io/en/stable/
Logo du sous-projet Jinja
Jinja est un puissant moteur de template utilisé par Flask pour produire du contenu mis en forme incluant les données collectées par l’application. La section consacrée à la gestion des vues et des routes a déjà présenté quelques exemples. Cette section va approfondir les concepts propres au moteur de template Jinja. Celui-ci est généralement utilisé pour produire du contenu HTML, mais il peut également produire du contenu XML, CSV, texte et autres.
Dans un template Jinja, le document contient des balises spéciales utilisant des accolades, interprétées et remplacées par le moteur de template en vue de produire le contenu final.
Jinja apporte des structures de contrôle de type if, else, for, les assignations, les filtres et les expressions à la génération de document.
Derrière ces fonctionnalités élémentaires, Jinja apporte également de puissants concepts comme l’héritage de documents, la gestion d’extension et l’inclusion.
La section précédente consacré à la gestion des routes et des vues indiquait que l’exécution d’un template depuis le code Python passe par la fonction render_template().
from flask import render_template
...
@app.route(’/’)
def fruit_list():
return render_template( ’fruit_edit.html’, id=id,
nom=nom, kcal=kcal, title=title )
La fonction render_template() reçoit le nom du fichier template à utiliser (ex. : fruit_edit.html) qui doit impérativement se trouver dans le sous-répertoire templates du projet. À noter que l’extension « .html » n’est qu’une convention.
La fonction render_template() peut également recevoir des paramètres nommés qui deviendront accessibles dans le template, ce qui est le cas des variables id, nom, kcal, title dans l’exemple ci-dessus.
Pouvoir tester les différents cas de figure peut s’avérer très enrichissant durant la phase d’apprentissage. Cette section présente plusieurs approches pour tester un template Jinja.
Parmi les différentes options, il existe :
1. | Créer une application Flask |
2. | Tester avec serveur web Flask et string Python |
3. | Tester en console avec string Python (SANS serveur Flask) |
4. | Utiliser le projet Jinja Live Parser |
Cette option est la plus simple, mais aussi la plus contraignante à mettre en œuvre. En effet, il est nécessaire de créer la structure de répertoires, gérer les routes, les fichiers template, etc.
Pour
•.Adapté pour les tests mettant en œuvre plusieurs routes, des feuilles de styles, des images, du code JavaScript, des structures de données complexes et des fonctionnalités avancées.
•.Convient à l’élaboration de templates longs et complexes.
Contre
Lourdeur de mise en œuvre (ex. : tester un cas de figure élémentaire ou tester une fonctionnalité Jinja).
Abordée à plusieurs reprises dans les différents exemples dans le répertoire /python/flask-demos/, cette option permet de préparer une mini application Flask tenant dans un seul fichier tout en exploitant le moteur de rendu Flask.
Ci-dessous le contenu du fichier /python/flask-demos/flask-mini-app.py dans le dépôt GitHub du projet.
# coding: utf8
from flask import Flask
app = Flask( __name__ )
template = """<!DOCTYPE html>
<html>
<body>
<h1>demo</h1>
{{ name }}
</body>
</html>""".decode(’utf8’)
@app.route(’/’ )
def test():
nom=’demo’
lst=[1,3,8,12]
return app.jinja_env.from_string( template ).render( nom=nom,valeur=lst)
app.run( debug=True, port=5000, host=’0.0.0.0’)
Pour
•.Facile à mettre en œuvre.
•.Permet d’activer le serveur web Flask à peu de frais.
•.Capable de gérer quelques routes (et quelques templates).
•.Peut produire du contenu HTML.
•.Peut être utilisé avec un navigateur Internet.
Contre
•.Inadapté aux templates complexes.
•.Ne peut pas charger de fichiers statiques (CSS, image).
•.Nécessite l’arrêt et le redémarrage de l’application pour modifier le template.
Il est également possible d’utiliser le moteur de rendu de Jinja pour traiter un template Jinja stocké dans une chaîne de caractères et générer une chaîne de caractères affichée directement dans la console.
Cette approche est tellement concise qu’elle peut être utilisée dans une session Python interactive.
pi@pythonic:~ $ python
Python 2.7.9 (default, Sep 17 2016, 20:26:04)
[GCC 4.9.2] on linux2
Type "help", "copyright", "credits" or "license" for more
information.
>>> from jinja2 import Template
>>> tmp = """---{% for itm in lst %}+-+{{ itm }}{% endfor %}---"""
>>> lst = [’Banane’, ’Orange’, ’Fraise’]
>>> Template( tmp ).render( lst=lst )
u’---+-+Banane+-+Orange+-+Fraise---’
Pour :
•.Très facile à mettre en œuvre.
•.Ne nécessite pas le démarrage d’un serveur web Flask et ne requiert pas de navigateur Internet.
•.Exploitable depuis une session Python interactive.
•.La méthode render() accepte une liste de variables, ex. : Template( tmp ).render( lst=lst, nom=’Dominique’ ).
•.La méthode render() accepte également un dictionnaire pour initialiser les variables du template Jinja, ex. : Template( tmp ).render({"variable_jinja": "valeur", "cle": "valeur2" }).
Contre :
•.Uniquement destiné aux tests élémentaires.
•.Mal adapté à la production de contenu HTML.
•.Utilisable uniquement en ligne de commande.
Le projet Jinja Live Parser, disponible sur le dépôt GitHub, est un outil convivial qui permet de tester le moteur de template Jinja depuis une interface web.
https://github.com/qn7o/jinja2-live-parser
Une fois téléchargé et les prérequis installés, Jinja Live Parser peut être démarré avec python parser.py.
$ git clone https://github.com/qn7o/jinja2-live-parser.git
$ pip install -r requirements.txt
$ python parser.py
Démarré comme n’importe quelle autre application Flask, l’interface de Jinja Live Parser est accessible depuis un navigateur Internet sur l’adresse http://127.0.0.1:5000/.
L’interface se présente comme suit :
La section Template permet de saisir le template Jinja à tester.
La section Render permet de voir le résultat du moteur de rendu une fois le bouton Convert pressé.
La section Settings permet de configurer les options du moteur de rendu. Il propose également le bouton Convert qui lance l’interprétation du template Jinja.
La section Values permet de saisir un dictionnaire de variables au format JSON. Chaque clé (ex. : name, test) représente le nom d’une variable Jinja accessible dans le template.
Pour :
•.Facile à mettre en œuvre.
•.Permet de réaliser un test de façon interactive.
•.Permet de tester rapidement une fonctionnalité Jinja.
•.Ne nécessite pas de codage Python pour réaliser un test.
•.Permet de voir les espaces (en option).
•.Facile à mettre en ligne (en déployant la solution sur Heroku ou Docker, voir le dépôt GitHub du projet).
Contre :
•.Uniquement destiné à des tests élémentaires.
•.Mal adapté à la production de contenu HTML (parfait pour du texte).
La base du fonctionnement de Jinja est l’interprétation de balises utilisant des accolades. Il y a trois types de balises auxquels se joint une option permettant de saisir des instructions déclaratives.
•.{{ ... }} pour l’évaluation d’expression
•.{% ... %} pour l’évaluation d’instructions de contrôle de flux
•.{# … #} pour insérer un commentaire dans un template
•.# … pour introduire l’évaluation d’une ligne d’instructions
Espace obligatoire
Le corps de la balise (ce qu’il y a entre les accolades) doit toujours être séparé par un espace des accolades. Ainsi, la balise {{ nom }} est correcte, alors que {{nom}} ne l’est pas ! De même, la balise {% if user.genre==’M’ %} est correcte, alors que {%if user.genre==’M’%} ne l’est pas !
La double accolade permet d’évaluer une variable ou une expression Jinja dont le résultat est inclus dans le document de sortie.
Jinja prévoit des instructions pour réaliser des structures de contrôle. Ces structures de contrôle permettent d’altérer le flux d’exécution du template et, par conséquent, d’influer sur le contenu généré par le template.
Parmi les structures de contrôle, il y a le branchement conditionnel réalisé avec les balises {% if %} et {% endif %} ainsi que la boucle d’itération avec {% for %} et {% endfor %}. Ces structures seront étudiées en temps voulu.
Dans tous les cas, l’évaluation des instructions de contrôle flux passent pas une ou plusieurs balises {% ... %}.
Contrôle de flux, indentation et espaces additionnels
Pour faciliter la lecture des templates, il est assez courant d’indenter leur contenu et les différentes instructions de contrôle de flux pour rendre le code plus facile à lire.
La conséquence directe de ces indentations est de retrouver des espaces additionnels dans le flux de sortie. Si cela n’a théoriquement pas d’importance en ce qui concerne la structure HTML, l’inclusion d’espaces aura plus d’impact sur le contenu textuel affiché. C’est la raison pour laquelle Jinja prévoit également des mécanismes de contrôle des espaces dans les balises Jinja. Ce point est également détaillé ultérieurement.
Jinja prévoit une balise de commentaire qui peut s’étendre sur plusieurs lignes. Les commentaires peuvent être utilisés pour :
•.documenter une particularité d’un template,
•.informer des collègues avec une note,
•.désactiver une partie d’un template.
Tout ce qui se trouve entre l’ouverture de balise {# et le symbole de fermeture de balise #} sera simplement ignoré par le moteur de template.
À titre informatif, Jinja prévoit également l’activation d’une option line statement permettant de traiter des instructions de contrôle de flux sur des lignes commençant par le caractère de préfixe # à la place des balises {% ... %}.
Lorsque l’option line statement est active, un code utilisant une instruction de branchement if :
<h1>
# if user.genre.upper() == ’M’
Monsieur
# else
Madame
# endif
{{ nom }}
</h1>
Fonctionne de façon identique à la structure utilisant des balises Jinja :
<h1>
{% if user.genre.upper() == ’M’ %}
Monsieur
{% else %}
Madame
{% endif %}
{{ nom }}
</h1>
Cependant, l’utilisation de line statement n’est pas une approche supportée par la communauté des développeurs Flask (cf. Forums Jinja).
Les templates Jinja utilisent des balises d’évaluation d’expression {{ ... }} pour inclure des variables et des expressions dans le flux généré par le template.
Par conséquent, si la variable nom est passée en paramètre à render_template() alors l’utilisation de {{ nom }} dans le template permet d’insérer la valeur de la variable nom dans le document produit.
Par exemple :
from flask import render_template
...
@app.route(’/’)
def afficher_variable():
nom = ’tux’
nom2 = ’le pingouin’
return render_template( ’voiture.html’, nom=nom, prenom=nom2 )
alors il devient possible d’afficher le contenu de la variable nom dans le template HTML en utilisant :
...
<h1>Nom</h1><br />
{{ nom }}, {{ prenom }}
...
L’exemple indique comment changer le nom d’un paramètre en appelant la fonction render_template(). En effet, avec la syntaxe prenom=nom2 la variable nom2 du script Python sera accessible avec le nom de variable prenom dans le template. C’est également pour cette raison que l’appel de render_template() utilise le formalisme nom=nom pour créer une variable de template nom nommée identiquement à la variable nom du script Python.
Étant donné qu’il s’agit de l’évaluation d’expressions Python (une variable étant aussi une expression élémentaire), il est possible de passer un objet au template et d’accéder aux différents attributs de l’objet depuis le template.
Par exemple :
from flask import render_template
...
@app.route(’/’)
def afficher_voiture():
voiture = get_voiture( id = 15 )
# voiture.modele.nom -> Beta Juliette
return render_template( ’voiture.html’, v=voiture )
Alors il devient possible d’afficher le nom du modèle dans le template HTML en utilisant :
...
<h1>{{ v.modele.nom }}</h1><br />
Année de fabrication : {{ v.modele.fabrication.annee_debut }}
...
Les expressions permettent également d’utiliser des fonctions (et des méthodes sur un objet), des opérations mathématiques et l’évaluation d’expressions ternaires comme valeur_1 if test==True else valeur_2.
Par exemple :
from flask import render_template
...
@app.route(’/’)
def combien_de_fruits():
fruits = [’banane’, ’orange’ ]
# fruit[0] -> banane
return render_template( ’compter_fruit.html’, fruits=fruits )
Il devient alors possible d’afficher le nombre d’éléments de la liste avec la fonction Python standard len() :
...
<h1>Info Fruit</h1><br />
La liste contient {{ len(fruits) }} fruits.
Premier Fruit est : {{ fruits[0] }}
...
Lorsque Flask effectue le traitement d’une requête, plusieurs objets sont accessibles dans le code Python (ex. : g, session, request et request.cookies). Lorsque Flask demande le rendu d’un template Jinja, plusieurs variables sont automatiquement communiquées au template dont :
•.request (donc également request.cookies)
•.session
•.g
•.config (la configuration Flask)
Leur utilisation dans des expressions est similaire à celle effectuée en Python.
L’application Flask /python/flask-demos/special-var-app/ disponible sur le dépôt GitHub de l’ouvrage démontre l’accès aux différentes variables disponibles depuis le template Jinja. Ce code source est à consulter pour en savoir plus sur le sujet.
Résultat de l’application Flask special-var-app
Cette application Flask capture toutes les URL et fournit un rendu des variables spéciales à l’aide du template specialvar.html.
Étant donné que Jinja utilise des doubles accolades pour l’évaluation d’expressions, faire apparaître une double accolade dans le document nécessite l’utilisation d’une astuce technique appelée séquence d’échappement.
Pour afficher une double accolade, il faut évaluer l’une des expressions suivantes en fonction du résultat souhaité :
<h1>Afficher une double accolade ouverte</h1><br />
{{ ’{{’ }}
<h1>Afficher une double accolade fermée</h1><br />
{{ ’}}’ }}
Une autre option consiste à utiliser un bloc d’instruction raw :
{% raw %} afficher les {{ et les }} sans interprétation de balise {% endraw %}
Bien qu’utilisée moins fréquemment, Jinja prévoit une balise d’assignation {% set %}. Cette balise permet d’assigner une valeur à une variable dans un template Jinja.
L’assignation supporte de multiples formats de données et peut également traiter de multiples paramètres.
{% set fruits = [ ’Banane’, ’Orange’, ’Framboise’ ] %}
{% set couleurs = [ (’Rouge’, ’#FF0000’), (’Vert’, ’#00FF00’), (’Bleu’, ’#0000FF’) ] %}
{% set R,G,B = 10,60,80 %}
{% set A,B,C = (15,’Demo’,R) %}
{% set nom, prenom = get_user_names( user_id ) %}
L’instruction de contrôle de flux if permet de réaliser des branchements à partir des balises {% if expression %} et {% endif %}. Ces balises permettent de traiter le contenu situé entre elles uniquement si l’expression retourne un résultat pouvant être évalué comme True.
Comme pour de nombreux autres langages de programmation, le contrôle de flux par branchement prévoit les balises complémentaires {% else %} et {% elif expression %}.
L’exemple suivant teste le genre de l’utilisateur pour afficher un libellé Monsieur, Madame, etc. devant le nom de l’utilisateur. L’objet user expose un attribut genre qui contient une chaîne de caractères.
<h1>
{% if user.genre.upper() == ’F’ %}
Madame
{% elif user.genre.upper() == ’M’ %}
Monsieur
{% elif user.genre.upper() == ’MS’ %}
Mademoiselle
{% else %}
(genre {{user.genre}} inconnu)
{% endif %}
{{ nom }}
</h1>
Qui produit :
•.le résultat « Madame Dominique » si genre contient « F » ou « f »,
•.le résultat « (genre x inconnu) Dominique » si genre contient « x ».
Étant donné que l’attribut genre est une chaîne de caractères Python (string), il est possible d’appeler les méthodes qu’elle expose. Par conséquent, genre.user.upper() retourne la valeur du genre en majuscule.
Jinja prévoit une instruction de contrôle de flux {% for x in collection %}{% endfor %} permettant une itération sur le contenu d’une collection en répétant une partie du template.
L’exemple suivant parcourt la liste fruits ( fruits=[’Banane’,’Mangue’, ’Ananas’] ) pour produire une liste à puce.
<h1>Liste des fruits</h1>
<ul>
{% for fruit in fruits %}
<li>{{ fruit }}</li>
{% endfor %}
</ul>
Il est également possible de parcourir des collections d’éléments plus complexes comme un dictionnaire ou une liste de tuple.
Par exemple, pour un dictionnaire dico défini par le code Python :
dico = { "0": "zéro", "1": "un", "2": "deux", "3": "trois", "4": "quatre",
"5": "cinq", "6": "six", "7": "sept", "8": "huit", "9": "neuf" }
x
Il est possible d’écrire les templates suivants :
<h1>Les chiffres</h1><br />
<ul>
{% for cle, valeur in dico.iteritems() %}
<li>{{ cle }} = {{ valeur }}</li>
{% endfor %}
</ul>
Pour le dictionnaire dico, la liste à bulle reprend l’énumération :
. 1 = un
. 0 = zéro
. 3 = trois
. 2 = deux
. 5 = cinq
. 4 = quatre
. 7 = sept
. 6 = six
. 9 = neuf
. 8 = huit
Les dictionnaires ne sont pas des éléments triés. Pour obtenir une liste triée, il faut utiliser le filtre sort dans la balise for. Ex. : {% for cle, valeur in dico.iteritems() | sort %}.
Filtrage des éléments
Tout comme cela est possible avec la List Comprehension de Python, il est possible de réduire l’ensemble des données de la boucle for en appliquant une condition de test.
Si la condition de test est évaluée à true pour l’élément, alors l’élément passe dans l’itération sinon il est ignoré.
Dans l’exemple précédent, la boucle for est modifiée pour filtrer les éléments supérieurs à 5.
<h1>Les chiffres</h1><br />
<ul>
{% for cle, valeur in dico.iteritems() if cle|int > 5 %}
<li>{{ cle }} = {{ valeur }}</li>
{% endfor %}
</ul>
Étant donné que le dictionnaire contient la valeur numérique sous forme de chaîne de caractères, il convient de transformer celle-ci en entier avant de la comparer à la valeur 5. En effet, la comparaison d’une chaîne de caractères > 5 est toujours vraie. La transformation vers un entier se fait à l’aide du filtre Jinja int d’où la notation cle | int. Les filtres Jinja sont abordés plus loin dans le chapitre.
Cette fois, le résultat retourné est :
7 = sept
6 = six
9 = neuf
8 = huit
Variables spéciales
À l’intérieur d’une boucle for, le moteur de template Jinja met à disposition une série de variables spéciales. Ces variables sont utilisables comme n’importe quelle autre variable Jinja.
Ces variables spéciales peuvent être utilisées pour altérer le flux de sortie en fonction de conditions spécifiques. Grâce à ces variables, il est possible d’alterner la couleur des lignes d’un tableau une ligne sur deux.
Variable | Description |
loop.index | Numéro d’itération de la boucle (commence à 1). Soit une séquence 1, 2, 3, 4, 5... |
loop.index0 | Numéro d’itération de la boucle (commence à 0). Soit une séquence 0, 1, 2, 3, 4... |
loop.revindex | Nombre d’itérations restant jusqu’à la fin de la boucle (correspondant à loop.index). Soit une séquence 5, 4, 3, 2, 1. |
loop.revindex0 | Nombre d’itérations restant jusqu’à la fin de la boucle (correspondant à loop.index0). Soit une séquence 4, 3, 2, 1, 0. |
loop.first | True lors de la première itération. |
loop.last | True lors de la dernière itération. |
loop.length | Le nombre d’éléments dans la séquence. |
loop.cycle | Fonction utilitaire permettant de cycliser une valeur parmi différents éléments d’une séquence. À chaque nouvelle itération de la boucle for, la valeur suivante est extraite de la séquence. Voir explications ci-dessous. |
loop.depth | Indique la profondeur de récursivité de la boucle for. Démarre au niveau 1. |
loop.depth0 | Indique la profondeur de récursivité de la boucle for. Démarre au niveau 0. |
loop.previtem | L’élément précédent de l’itération. Indéfini s’il s’agit de la première itération. |
loop.nextitem | L’élément suivant de l’itération. Indéfini s’il s’agit de la dernière itération. |
loop.changed(*val) | True si la valeur en paramètre a changé depuis le dernier appel (ou n’a jamais été appelée). La fonction loop.change (*val ) permet de changer la couleur des lignes d’un tableau uniquement si une valeur clé change d’une ligne à l’autre. |
L’exemple suivant fait un rendu des lettres du mot Arc-en-Ciel (code Python lst = list( ’Arc-en-Ciel’ ) ) en utilisant un cycle de couleurs.
<strong>
{# lst obtenu avec
{% for element in lst %}
<font color="{{ loop.cycle( "Tomato", "Orange", "DodgerBlue",
"MediumSeaGreen", "Gray", "SlateBlue", "Violet", "LightGray" ) }}">
{{ element }}</font>
{% endfor %}
</strong>
Ce qui produit le résultat (en couleur) suivant lorsque le template Jinja produit la page HTML.
Résultat de loop.cycle()
Altérer le comportement itératif
Il est possible de modifier le comportement de la boucle for à l’aide des balises {% continue %} ou {% break %}.
La balise {% continue %} permet de démarrer immédiatement la prochaine itération de la boucle {% for %}.
La balise {% break %} permet d’interrompre immédiatement la boucle {% for %} et de poursuivre le traitement du template juste après la balise {% endfor %}.
Plus d’informations
Il reste de nombreuses fonctionnalités à découvrir sur l’itération (détection de parité, l’itération récursive, etc.). Un complément d’information est disponible sur la page suivante : http://jinja.pocoo.org/docs/2.10/templates/#list-of-control-structures
Les macros Jinja se définissent un peu comme une fonction pour Python sauf qu’elles s’appliquent au template Jinja. Comme les fonctions, les macros acceptent des paramètres qui peuvent ensuite être utilisés pour produire du contenu.
Une macro se définit avec les balises {% macro nom_de_la_macro(param) %} et {% endmacro %} et contient les éléments à générer dans le flux de sortie lorsqu’elle est appelée.
Une macro simple
Une macro s’appelle avec les balises {% call nom_de_la_macro(param) %} et sa balise complémentaire {% endcall %}, cela signifie qu’il est possible d’inclure des éléments de flux entre ces deux balises, éléments que la macro a le loisir de « récupérer ». Ce contenu est accessible en évaluant la balise {{ caller() }} depuis la macro.
L’exemple suivant définit une macro qui dessine une boîte avec un élément <div>. La macro reçoit un paramètre titre et un paramètre color (pour la couleur du bord).
Pour finir, la macro est appelée depuis le template Jinja.
{% macro faire_boite(title, color=’green’) -%}
<div style="border-color:{{ color }}; border-width: 3px;
border-style: solid;">
<h2>{{ title }}</h2>
<p>{{ caller() }}</p>
</div>
{%- endmacro %}
<!DOCTYPE html>
<meta charset="UTF-8">
<html>
<body>
<h1>Démo macro Jinja</h1><br />
{% call faire_boite(’Bonjour à tous’) %}
Ceci est un exemple de définition et d’appel
de macro. Il est même possible d’évaluer des
variables dans le corps de l’appelant... commme
par exemple "{{ nom }}".
{% endcall %}
<br />
{% call faire_boite(’Interessant’, color=’red’) %}
Bien entendu, les macro peuvent servir à
de nombreuses autres choses!
{% endcall %}
</body>
</html>
Ce qui produit le résultat suivant :
Démonstration de la macro faire_boite
L’exemple met en évidence le fait que le bloc appelant peut utiliser des balises pour évaluer des variables (voir {{ nom }} ) dans le bloc appelant la macro.
Bloc appelant avec paramètre
Une macro a également la possibilité d’évaluer le bloc appelant {{ caller() }} en spécifiant un paramètre {{ caller(un_parametre) }}, paramètre qui peut être exploité dans le bloc appelant.
Par exemple, la macro suivante liste les utilisateurs en permettant à l’appelant de préciser des informations complémentaires pour chacun des utilisateurs.
{% macro lister_utilisateurs( liste_util ) %}
<ul>
{% for utilisateur in liste_util %}
<li><p>{{ utilisateur.login }} - {{ utilisateur.nom }}</p>
{# Evaluer le bloc appelant en passant #}
{# l’utilisateur courant en paramètre #}
{{ caller(utilisateur) }}
</li>
{% endfor %}
</ul>
{% endmacro %}
Le bloc appelant la macro lister_utilisateurs doit alors préciser un nom de paramètre qu’il recevra lors de l’appel de {{ caller( utilisateur ) }} dans la macro.
La syntaxe de balise {{ call }} précise donc le paramètre utilis à recevoir {% call(utilis) lister_utilisateurs(liste_utilisateurs) %} !
Le paramètre utilis reçu lors de l’évaluation du bloc {% call %} / {% endcall %} sera celui communiqué par l’évaluation de {{ caller(utilisateur) }} dans la macro.
<h1>Liste des utilisateurs</h1>
{% call(utilis) lister_utilisateurs(liste_utilisateurs) %}
<p>Surnom: {{ utilis.surnom }}<br />
Hobby: {{ utilis.hobbies.text }}</p>
{% endcall %}
Voir également la documentation Jinja concernant la définition de macro : http://jinja.pocoo.org/docs/2.10/templates/#macros
Il est courant d’indenter le contenu d’un template pour faciliter la lecture de son contenu. Cependant, ces indentations introduisent des espaces additionnels dans le flux de sortie.
Lorsque le template produit un contenu HTML, ces espaces peuvent parfois perturber l’interprétation du code HTML par le navigateur et le rendu des pages HTML. Pour éviter ce type de problème, Jinja introduit un mécanisme de contrôle des espaces dans le flux de sortie.
Dans son comportement par défaut, Jinja :
•.élimine le dernier retour à la ligne (1 seul uniquement) en fin de template si ce dernier est présent seul sur la dernière ligne,
•.transmet tous les espaces, toutes les tabulations, tous les retours de ligne vers le flux de sortie.
Ainsi, le template suivant :
<h1>Démo espace</h1><br />
{{ nom }}
Info text
produit le résultat :
<h1>Démo espace</h1><br•/>
Dominique
Info text
Jinja prévoit l’addition du signe « - » à l’avant et/ou l’arrière d’une balise Jinja {% ... %} ou {{ ... }} pour éliminer les espaces.
Éliminer à l’arrière
Placé à l’arrière d’une balise Jinja, le « - » enlève les espaces (et retours à la ligne) jusqu’au premier caractère suivant la balise. Par conséquent, le template suivant :
<h1>Démo espace</h1><br />
{{ nom -}}
Info text
produit le résultat HTML suivant où il est clairement visible que les espaces ont été enlevés derrière la balise {{ nom }} :
<h1>Liste des utilisateurs</h1><br />
DominiqueInfo text
Éliminer à l’avant
Placé à l’avant d’une balise Jinja, le « - » enlève les espaces (et retours à la ligne) jusqu’au premier caractère précédent la balise. Par conséquent, le template suivant :
<h1>Démo espace</h1><br />
{{- nom }}
Info text
produit le résultat HTML suivant où il est clairement visible que les espaces ont été enlevés à l’avant de la balise {{ nom }} :
<h1>Liste des utilisateurs</h1><br />Dominique
Info text
Étant donné que le contrôle des espaces (et des retours à la ligne) est placé sur les balises Jinja, ceux-ci ne seront pas supprimés entre les balises HTML.
Exemple
Dans l’exemple suivant, la séquence seq = [’A’, ’B’, ’C’, ’D’, ’E’, ’F’] peut être affichée avec le template :
{% for el in seq %}
{{ el }}
{% endfor %}
Produisant le résultat suivant s’il n’y a pas de suppression d’espace.
A
B
C
D
E
F
En supprimant les espaces, à l’aide du template légèrement modifié :
{% for el in seq -%}
{{ el }}
{%- endfor %}
Le résultat est, cette fois, entièrement différent !
ABCDEF
Voir http://jinja.pocoo.org/docs/2.10/templates/#whitespace-control.
Les filtres sont utilisés dans les balises d’expressions {{ ... }} et permettent d’effectuer un traitement sur l’expression évaluée avant de l’inclure dans le flux de sortie. Un filtre est donc une fonction qui reçoit un flux, le transforme et sort un flux transformé.
Resultat = le_filtre( valeur_de_l_expression )
Un exemple typique de filtre est la suppression des espaces excédentaires, la mise en majuscule (uppercase), le formatage de titre (première lettre de chaque mot en majuscule), etc.
Le filtre est mentionné après l’expression dont il est séparé par un caractère pipe « | ».
L’exemple suivant insère le nom inversé et mis en majuscules dans le flux de sortie :
{{ nom | reverse | upper }}
Si nom contenait la valeur Tux, le résultat inséré dans le flux de sortie serait XUT. S’il fallait utiliser des fonctions, l’exemple correspondrait à upper( reverse( nom ) ), la notation utilisant le filtre reste cependant plus lisible.
Jinja prend également en charge des filtres paramétrables. Il s’agit alors de fonctions filtres avec des paramètres complémentaires.
Resultat = le_filtre( valeur_de_l_expression, paramètre1=None, Paramètre2=None, ... )
Dans l’exemple suivant, le filtre replace va remplacer tous les « ux » par « uxedo » avant de produire un résultat en capital :
{{ nom | replace(’ux’,’uxedo’) | upper }}
Ce qui produit le résultat « TUXEDO-SUR-PI » pour une variable nom contenant « Tux-sur-Pi ».
Étant donné que les filtres utilisent des fonctions Python, il est également possible de nommer les paramètres de la fonction filtre lors de l’appel du filtre. L’exemple précédent réécrit : {{ nom | replace(old=’ux’, new=’uxedo’) | upper }}.
Pour achever cette introduction, il est important de souligner que le système de filtre ne s’applique pas uniquement à du contenu de type texte. Les filtres peuvent également traiter des listes d’objets, des attributs, réaliser du transtypage, des calculs d’arrondis, etc.
La liste ci-dessous reprend quelques-uns des filtres les plus intéressants, regroupés par fonctionnalité logique.
Filtre | Prototype et description |
Formatage de chaîne de caractères | |
capitalize | capitalize(s) Le premier caractère passe en majuscule, tous les autres en minuscule. |
center | center(value, width=80) Centre une valeur dans un champ d’une valeur donnée. |
default | default(value, default_value=u’’, boolean=False) Si la valeur value est indéfinie, alors la fonction retourne la valeur par défaut default_value sinon c’est la valeur value qui est retournée. {{ ma_variable|default(’Oups ! Indéfini’) }} Le paramètre boolean permet d’indiquer que la valeur value doit être évaluée en booléen. Si le résultat booléen est False alors la valeur par défaut default_value est retournée. Cela permet de tester une chaîne vide comme False. {{ ’’|default(’la chaine est vide’, True) }} |
lower | lower(s) Convertit une valeur en minuscule. |
upper | upper(s) Convertit une valeur en majuscule. |
trim | trim(value) Enlève les espaces à l’avant et à l’arrière d’une chaîne de caractères. |
format | format(value, *args, **kwargs) Applique le formatage d’un ou plusieurs arguments en utilisant la valeur value comme chaîne de formatage. {{ "%s - %s"|format("Valeur", "trois") }} Ce qui produit le résultat « Valeur - trois », équivalent du code Python "%s - %s" %("Valeur", "trois"). |
replace | replace(value, old, new, count=None) Modifie la chaîne de caractères value pour remplacer toutes les occurrences de old par la valeur de new. Le paramètre count permet d’indiquer le nombre d’occurrences à remplacer. {{ "Bonjour toi"|replace("Bonjour", "Au revoir") }} Ce qui produit le résultat « Au revoir toi ». |
escape | escape(value) Convertit les caractères &, <, >, ‘ et ” contenus dans value en leurs entités HTML respectives. La fonction escape est utilisée pour inclure du texte qui utiliserait de tels caractères. {{ "le tag <br />" | escape }} produit le résultat "le tag <br />". |
urlencode | urlencode(value) Convertit le contenu d’une URL en utilisant les séquences d’échappement (encodée en UTF-8). Cette fonction accepte string et dictionnaire en paramètre. |
urlize | urlize(value, trim_url_limit=None, nofollow=False, target=None, rel=None) Convertit une URL en texte cliquable (avec une balise HTML <a>). Le filtre peut recevoir un paramètre additionnel pour raccourcir le texte associé à l’URL. Le paramètre target permet de définir une destination pour le lien. {{ une_url_texte|urlize(30, target=’_blank’) }} Lien raccourci à 30 caractères et spécifie l’attribut target pour le lien généré afin d’ouvrir un nouvel onglet dans le navigateur. |
striptags | striptags(value) Enlève les tags SGML/XML et remplace les espaces adjacents par un espace unique. |
Typage et traitement mathématique | |
string | string(object) Convertit un objet en chaîne de caractères unicode si ce n’est pas déjà le cas. |
float | float(value, default=0.0) Convertit la valeur value en un nombre à virgule flottante. Le paramètre default permet d’indiquer la valeur à retourner si la conversion échoue. |
int | int(value, default=0, base=10) Convertit une valeur value en entier. Le paramètre default permet de modifier la valeur retournée lorsque la conversion échoue (donc 0). Le paramètre base permet de mentionner une autre base pour la conversion (2 pour binaire, 8 pour octal, 16 pour hexadécimale). Dans ce cas, le paramètre default peut s’exprimer avec des préfixes 0b (binaire), 0o (octal) et 0x (hexadécimal). |
round | round(value, precision=0, method=’common’) Arrondit la valeur value en utilisant la précision mentionnée (nombre de décimales). Le paramètre method permet de définir la méthode d’arrondi à utiliser :
{{ 12.51|round }} Produit la valeur 13.0 {{ 12.51|round(1, ’floor’) }} Produit le résultat 12.5 (arrondi vers le bas avec une décimale). Note : Le filtre round produit toujours un nombre en virgule flottante, même avec une précision de 0. Si le résultat doit être un entier, il faut alors filtrer le résultat avec int. Ex. : {{ 12.51 | round( 0, ’floor’ ) | int }} qui lui produira 13. |
abs | abs(value) Retourne la valeur absolue de la valeur value. |
Traitement de collection | |
list | list(value) Convertit la valeur value en liste. Si value est une chaîne de caractères, alors le filtre list retourne une liste de caractères. |
first | first(seq) Retourne le premier élément d’une séquence (ou liste). |
last | last(seq) Retourne le dernier élément d’une séquence (ou liste). |
max | max(value, case_sensitive=False, attribute=None) Retourne la valeur maximale existant dans une séquence. Ce filtre peut traiter des valeurs entières ou des chaînes de caractères. {{ [1, 2, 3]|max }} Retourne la valeur 3. Le paramètre case_sensitive (False par défaut) permet de traiter les chaînes de caractères en fonction de leur casse. Le paramètre attribute permet d’extraire la valeur d’un attribut dans le cas où value est une collection d’objets. |
min | min(value, case_sensitive=False, attribute=None) Similaire au filtre max mais retourne la valeur minimale. |
length | length(objet) ou count(objet) Retourne le nombre d’éléments dans une séquence (ou liste). |
join | join(seq, d=u’’, attribute=None) Retourne une chaîne de caractères concaténant les éléments de la séquence (ou la liste) avec un séparateur d inséré entre chaque valeur. Par défaut, le séparateur est une chaîne de caractères vide. Ex. : {{ [10, 22, 13] | join(’~’) }} Ce qui produit le résultat « 10~22~13 ». Comme pour les filtres min et max, le paramètre attribut permet d’extraire la valeur d’un attribut lorsque value est une séquence d’objets. Ex. : {{ utilisateurs | join( ’, ’ , attribute=’login’) }} |
slice | slice(seq, slices, fill_with=None) Découpe une liste (ou itérateur) pour créer une liste de listes où chaque sous-liste contient slices éléments. Le filtre slice est pratique, par exemple, pour découper une longue liste d’éléments pour réaliser un affichage des éléments en colonnes. Cela permet de réaliser un affichage type « navigateur de fichiers ». |
sort | sort(value, reverse=False, case_sensitive= False, attribute=None) Réaliser un tri ascendant sur une séquence (ou une liste) communiquée dans le paramètre value. Le paramètre reverse permet d’obtenir un tri descendant s’il est placé à True. Si la séquence contient des chaînes de caractères, alors le paramètre case_sensitive permet de réaliser un tri sensible à la case. Pour terminer, le paramètre attribute permet d’extraire la valeur de tri depuis un attribut particulier lorsque la séquence contient des objets. |
tojson | tojson(value, indent=None) Permet de transformer une structure en format JSON, ce qui permet d’injecter des données dans un tag <script>. Ce filtre utilise la fonction dumps() du module Python json. |
Autres | |
safe | safe(value) Marque la valeur value comme étant ’sûre’. Dans un environnement utilisant l’échappement automatique, cette valeur ne sera plus traitée pour transformer certains caractères en entité HTML. |
pprint | pprint(value, verbose=False) Effectue un Pretty Print d’une variable, d’un objet ou d’une structure. Le Pretty Print utilise un format de rendu plus agréable à lire, ce qui facilite les opérations de débogage. |
La balise {% include ’un_template.html’ %} permet d’inclure un template en lieu et place de la balise.
Par défaut, le template inclus peut accéder aux variables du contexte Jinja, ce qui permet de personnaliser le contenu produit par les templates inclus.
L’exemple suivant inclut deux templates page_header.html et page_footer.html et qui produisent respectivement le rendu de l’en-tête (titre page inclus) et du pied de page.
Le projet se présente comme suit :
include_app
├── app
│ ├── __init__.py
│ ├── static
│ ├── templates
│ │ ├── main.html
│ │ ├── page_footer.html
│ │ └── page_header.html
│ └── views.py
└── runapp.py
Il s’agit d’une structure d’application Flask comme décrite précédemment, le fichier runapp.py permettant de démarrer facilement l’application.
Le fichier views.py, ci-dessous, ne prend en charge que la route racine, le but est d’appeler le moteur de template pour réaliser un rendu de main.html. À noter que le template invoqué reçoit une variable titre.
# coding: utf8
from app import app
from flask import render_template
@app.route(’/’)
def page_pincipale( ):
return render_template( ’main.html’,
titre=’Bienvenu sur la page principale’ )
Le fichier main.html construit le rendu du corps de page sans oublier d’inclure le template d’en-tête page_header.html et de pied page_footer.html.
{% include ’page_header.html’ %}
<p>Ceci est un exemple d’inclusion de template Jinja.<p>
<p>Le template "page_header.html" prend en charge le rendu jusqu’à la balise
<body> et <h1> incluse.</p>
<p>Le template "page_footer.html" prend la fin de la page html à partir du tag
en </body></p>
<p>De surcroît, cette exemple démontre que le template inclus peut évaluer les
variables Jinja comme la balise {{’{{’}} titre {{’}}’}} évaluée à
"{{ titre|default("") }}"</p>
{% include ’page_footer.html’ %}
Les fichiers page_header.html et page_header.html sont respectivement constitués comme suit :
<!DOCTYPE HTML>
<html>
<head>
<title>{{ titre | default( ’-sans titre-’) }}</title>
</head>
<body>
<h1>{{ titre | default( ’-sans titre-’) }} </h1>
L’utilisation du filtre default permet d’éviter un message d’erreur si la variable titre est manquante lors de l’appel du template.
</body>
</html>
Cet exemple produit le résultat suivant :
Exemple d’inclusion de template
Options
Notez que {% include %} prévoit l’option « ignore missing » qui ne produit pas d’erreur et ignore simplement la balise {% include ’un_template.html’ %} si un_template.html est manquant.
Les options « with context » et « without context » permettent d’inclure ou d’exclure le contexte (les variables du template appelant) lors de l’inclusion avec la balise {% include %}.
Ci-dessous toutes les syntaxes include disponibles :
{% include ’page_header.html’ %}
{% include ’page_header.html’ ignore missing %}
{% include ’page_header.html’ ignore missing without context %}
{% include ’page_header.html’ ignore missing with context %}
La balise {% import %} montre un fonctionnement similaire à l’instruction import de Python. Le plus souvent, cette balise permet d’importer des fichiers contenant des macros devant être utilisées dans plusieurs templates.
L’inconvénient des fichiers importés, c’est qu’ils n’ont pas accès aux variables du contexte local, mais uniquement au contexte global.
Par contre, les fichiers importés sont chargés en cache, ce qui permet d’améliorer significativement les performances.
Dans l’exemple suivant, le fichier /templates/forms.macro contient différentes macros pour générer des parties de formulaire HTML.
{% macro input(name, value=’’, type=’text’) -%}
<input type="{{ type }}" value="{{ value|e }}" name="{{ name }}">
{%- endmacro %}
{% macro submit(name, text=’submit’) -%}
<button type="submit" name="{{ name }}" value="{{ text }}">
{%- endmacro %}
Le template /template/fruit_edit.html du projet de démonstration fruits-app (voir dépôt GitHub du projet dans le répertoire /python/flask-demos/fruits-app/ ) pourrait alors être réécrit comme suit en utilisant les balises {% import %} et {% include %} :
{% include ’page_header.html’ %}
{% import ’forms.macro’ as forms %}
<form action="{{ url_for(’fruit_edit’, id=id if id>=0 else None )
}}" method="post">
<table border="1">
<tr>
<td>Nom</td>
<td>{{ forms.input( ’nom’, value=nom ) }}</td>
</tr>
<tr>
<td>KCal / 100gr</td>
<td>{{ forms.input( ’kcal’, value=kcal ) }}
</tr>
<tr><td align="right" colspan="2">
{{ forms.input( ’id’, value=id, type=’hidden’ ) }}
{{ forms.submit( ’send_fruit’, text=’Envoyer’ ) }}
<input type="button" onclick="location.href=
’{{ url_for(’fruit_list’) }}’;" value="Abandonner" />
</td></tr>
</table>
</form>
{% include ’page_footer.html’ %}
import ... as
La balise {% import %} propose une syntaxe plus complète permettant d’importer des éléments du fichier dans l’espace de nom courant.
Il est en effet possible de réaliser une importation à l’aide de :
{% from ’forms.macro’ import input as input_field, submit as submit_button %}
Ce qui permet de modifier les appels de :
<td>{{ forms.input( ’nom’, value=nom ) }}</td>
...
{{ forms.submit( ’send_fruit’, text=’Envoyer’ ) }}
Vers :
<td>{{ input_field( ’nom’, value=nom ) }}</td>
...
{{ submit_button( ’send_fruit’, text=’Envoyer’ ) }}
import ... with context
La balise {% import %} est surtout utilisée pour importer des macros. Elle ne met pas le contexte local à disposition de celles-ci. Cette particularité permet à Jinja de placer le fichier importé en cache.
Jinja propose la syntaxe {% from ’forms.macro’ import input with context %} qui permet au fichier importé d’accéder au contexte. La conséquence directe est l’exclusion du cache. Cette syntaxe est à éviter pour ne pas pénaliser inutilement les performances du moteur de rendu.
Voir aussi la balise {% include %} qui, elle, charge le fichier en donnant accès au contexte local.
S’il y a bien une fonctionnalité Jinja à ne pas rater, c’est l’héritage !
Le procédé d’héritage permet de définir un « template de base », comme une page « squelette » qui prévoit l’emplacement de blocs « vides » qui seront définis plus tard dans un autre template Jinja.
Un template Jinja peut alors faire dériver son contenu du « template de base » puis définir ou préciser les blocs du « template de base » à remplir.
L’héritage de template est avant tout utilisé pour définir la structure HTML générale d’un site (en-tête, barre de menu, fil d’Ariane, pied de page, zone d’affichage, etc.) afin de maintenir une uniformité visuelle pour l’ensemble des pages du site.
Les deux illustrations suivantes présentent ce principe d’héritage du template de base.
Principe de l’héritage de template
Principe de l’héritage de template (exemple 2)
Comme les illustrations ci-dessus l’indiquent, l’héritage fait intervenir un template de base, un mécanisme d’extension ( {% extends %} ), la définition des blocs ( {% blocks %} ) à préciser dans le template enfant (les templates OSCAR et STEVE).
Plus formellement, l’héritage Jinja est articulé autour des éléments suivants :
•.le template de base
•.{% block %} : les balises blocs
•.{% extends %} : template enfant qui réalise une extension d’un template de base
•.super() : appel du super bloc
Les principes d’héritages Jinja sont abordés dans l’exemple heritage-app disponible sur le dépôt GitHub du projet (dans le répertoire /python/flask-demos/heritage-app/ ).
L’application est constituée des éléments suivants :
heritage-app
├── app
│ ├── __init__.py
│ ├── static
│ │ └── style.css
│ ├── templates
│ │ ├── base.html
│ │ ├── main.html
│ │ └── super.html
│ └── views.py
└── runapp.py
•.views.py : prend en charge les routes / et /super démontrant les fonctionnalités de l’héritage de template.
•.base.html : définition du « template de base » qui sera par la suite utilisé par les templates enfants.
•.main.html : template enfant dérivé de base.html. Démontre la définition et l’usage des balises {% block %}.
•.super.html : template enfant dérivé de base.html. Démontre l’usage de super() dans une balise {% block %}.
views.py
Sans surprise, views.py définit les routes / et /super qui font respectivement appel aux templates main.html et super.html.
# coding: utf8
from app import app
from flask import render_template
@app.route(’/’)
@app.route(’/<string:nom>’)
def main( nom = None ):
return render_template( ’main.html’, name=nom )
@app.route(’/super’)
def super():
return render_template( ’super.html’ )
Le « template de base » définit :
•.la structure HTML de la réponse,
•.l’emplacement des différentes balises {% block nom_de_bloc %} et {% endblock %} qui prendront place dans la réponse. Ces blocs devront être remplis par les « templates enfants ».
Les balises {% block %} ont trois caractéristiques importantes :
1. | Elles sont nommées. |
2. | Elles ont accès au contexte local et peuvent donc évaluer des variables et expressions. |
3. | Elles peuvent avoir une valeur par défaut, utilisée lorsque le bloc n’est pas redéfini dans le « template enfant ». |
Dans l’exemple heritage-app, le « template de base » base.html est constitué comme suit :
<!DOCTYPE HTML>
<html>
<header>
{% block head %}
<link rel="stylesheet" type="text/css"
href="{{ url_for( ’static’, filename=’style.css’ ) }}" />
<title>{% block title %}{% endblock %} - Heritage-App</title>
{% endblock %}
</header>
<body>
<div id="content">{% block content %}{% endblock %}</div>
<div id="footer">
{% block footer %}
-- La Maison Pythonic @ <a href="https://github.com/mchobby/la-maison-pythonic">GitHub</a> --
{% endblock %}
</div>
</body>
</html>
Ce « template de base » reprend clairement la structure d’une page HTML avec une section en-tête <head> et un corps de page avec <body>.
À noter que le corps de page est déjà scindé en deux éléments (des <div>) permettant d’organiser respectivement le contenu à afficher <div id="content"> et un pied de page <div id="footer">.
Le template de base définit les balises {% block %} suivantes qui devront être remplies par la « page enfant » :
•.title : ce bloc est vide, car il est destiné à être évalué dans l’un des blocs enfants. Il permettra d’ajouter l’élément de titre de la page HTML.
•.content : ce bloc, également vide, permet d’indiquer le contenu à afficher dans le <div id="content">. Ce bloc permet d’injecter le contenu HTML.
•.footer : ce bloc, pré-initialisé, permet de personnaliser le pied de page. Si le bloc {% block footer %} n’est pas redéfini dans le « template enfant », alors ce dernier affichera le contenu par défaut « -- La Maison Pythonic @ GitHub -- » prévu dans le template de base.
•.head : ce bloc définit le contenu de la section <header> de la page HTML, incluant la balise <titre> ainsi que le chargement de la feuille de style. Ce bloc peut être personnalisé pour, par exemple, inclure des styles supplémentaires (voir exemple de /super et super.html).
Dans les projets plus étendus, il est préférable de séparer les templates et « templates de base ». Une option consiste à placer les « templates de base » dans le sous-répertoire /templates/layout. Par conséquent, la balise extends est utilisée avec la syntaxe {% extends "layout/base.html" %}.
Le « template enfant » est celui appelé par une route et il définit :
•.Le « template de base » à utiliser à l’aide de la balise {% extends "template_de_base" %}.
•.La définition des différentes balises {% block nom_de_bloc %} renseignées dans le « template de base ». Si ces balises sont omises dans le « template enfant », alors la valeur par défaut du « template de base » est utilisée, valeur qui peut être vide.
Dans l’exemple heritage-app, le « template enfant » main.html est constitué comme suit :
01: {% extends "base.html" %}
02: {% block title %}Accueil{% endblock %}
03: {% block content %}
04: {% if name %}
05: <h1>Bienvenu {{name}}</h1>
06: {% else %}
07: <h1>Page Principale</h1>
08: <p>Vous pouvez également appeler la page
09: avec <strong>/votre_prénom</strong>
10: {% endif %}
11: <p> Partir à la découverte de l’héritage
12: des templates Jinja. </p>
13: <p>Voici le template main.html qui est une
14: extension de base.html.<br />
15: Cette page définit les blocs (<em>block</em>)
16: "title" et "content".</p>
17: <p>L’appel de <strong>/super</strong> permet
18: de tester l’exécution du "super block".</p>
19: {% endblock %}
•.Ligne 1 : indiquer que le template étend la définition du « template de base » base.html.
•.Ligne 2 : définit le contenu du bloc title en fournissant un titre à la page.
•.Lignes 3 à 19 : définition du contenu du bloc content qui sera inséré dans l’élément <div id="content">, ce dernier peut contenir des balises Jinja comme n’importe quel autre template.
À noter que les blocs {% block header %} et {% block footer %} ne sont pas définis. Par conséquent, le moteur de template utilisera les valeurs par défaut définies dans le template de base base.html.
L’exécution de l’application et l’appel de la page racine produisent le résultat suivant :
Résultat de l’appel de la page racine
Dans l’exemple heritage-app, le « template de base » base.html définit un bloc head pour couvrir la définition du <header> de la page HTML.
Cela permet de remplacer entièrement le contenu par défaut de l’élément <header> proposé dans le « template de base », une approche radicale, mais fonctionnelle.
Il existe cependant une approche plus élégante utilisant le « super bloc ».
En effet, Jinja propose l’évaluation de la balise {{ super() }} qui permet de récupérer l’évaluation du bloc dans le « template de base » afin de l’insérer dans le template enfant.
L’exemple ci-dessous propose d’utiliser {{ super() }} pour compléter le contenu du bloc {% block head %} avec l’ajout de la définition CSS suivante :
p.local-def {
color: #007700;
background-color: #FFFF00;
padding: 25px 25px 25px 25px;
border-style: solid;
border-width: 2px;
border-color: #007700;
}
La route /super fait le rendu du template super.html, template défini comme suit :
01: {% extends "base.html" %}
02: {% block title %}Super Block{% endblock %}
03: {% block head %}
04: {{ super() }}
05: <style type="text/css">
06: p.local-def {
07: color: #007700;
08: background-color: #FFFF00;
09: padding: 25px 25px 25px 25px;
10: border-style: solid;
11: border-width: 2px;
12: border-color: #007700;
13: }
14: </style>
15: {% endblock %}
16: {% block content %}
17: <h1>Utilisation du Super Block</h1>
18: <p> Cette page utilise l’héritage des templates Jinja
19: mais en redéfinissant une partie du bloc <strong>
20: head</strong> afin d’y ajouter des définitions
21: de style CSS. </p>
22 <p>L’appel de {{ ’{{’ }} super() {{ ’}}’ }} dans le
23: bloc <strong>head</strong> permet d’évaluer et
24: insérer le contenu du bloc <strong>head défini
25: dans le "tempate de base"<strong>.</p>
26:
27: <p class="local-def">Ce <p> utilise la classe
28: CSS <strong>local-def</strong> ajoutée dans le
29: bloc <strong>head</strong>.</p>
30: {% endblock %}
•.Ligne 1 : utilisation de la balise {% extends %} pour étendre le template de base.
•.Ligne 2 : définit le contenu du bloc title (que l’on retrouve dans l’onglet du navigateur).
•.Ligne 3 : définit le contenu du bloc head destiné à remplacer la valeur par défaut du template de base.
•.Ligne 4 : évaluation de super() pour récupérer le contenu du bloc head défini dans le template de base.
À noter que le bloc head du template de base n’inclut pas les éléments <header> et </header>, ce qui permet d’en compléter le contenu.
•.Lignes 5 à 14 : ajout du complément de style dans la section <header> en complétant le contenu renvoyé par super().
•.Lignes 16 à 30 : définition du contenu du bloc content qui sera inséré dans l’élément <div id="content">, contenu utilisant le style additionnel défini dans le bloc head.
L’exécution de l’application et l’appel de la page /super produisent le résultat suivant :
Utilisation de super() pour compléter un bloc du template
L’héritage des templates ne se résume pas aux seuls points abordés ci-avant. La documentation Jinja propose également des informations complémentaires : http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance
Message Flash (Message Flashing dans la littérature Flask) est une autre fonctionnalité importante de Jinja. Message Flash prend en charge un mécanisme de notification utilisateur. Il permet par exemple d’indiquer un message d’erreur comme « L’adresse doit être mentionnée » lorsque l’utilisateur n’encode pas l’information requise avant de poster un formulaire. Message Flash permet également d’afficher une notification « Enregistrement sauvegardé » en haut d’une liste d’enregistrements ré-affichée suite à une modification réussie.
Le système de Message Flash permet d’enregistrer des messages en fin de requête pour les avoir à disposition lors de l’exécution de la requête suivante (et uniquement la requête suivante).
En utilisant un template de base (voir Héritage Jinja), la re-capture du ou des messages enregistrés peut être réalisée dans le template de base pour organiser leur affichage.
Le mécanisme de messages utilise les cookies, ce qui limite la grandeur des messages (ou informations) pouvant être communiqués d’une requête à l’autre. La taille limite du message dépend du navigateur utilisé. Dans certains cas, le navigateur refusera les cookies trop longs, ce qui aura pour conséquence de faire disparaître l’information sans que cela puisse être détecté.
L’exemple flash-message-app disponible sur le dépôt GitHub du projet dans le répertoire /python/flask-demos/flask-app/flash-message-app/ démontre l’usage du Message Flash.
Structure
Le projet utilise la structure suivante :
flash-message-app
├── app
│ ├── __init__.py
│ ├── static
│ │ └── style.css
│ ├── templates
│ │ ├── base.html
│ │ ├── edit_message.html
│ │ ├── edit_nom.html
│ │ └── main.html
│ └── views.py
└── runapp.py
•.style.css : fichier CSS permettant, entre autres, d’appliquer un style sur les éléments de Message Flash.
•.base.html : template de base pour toutes les pages (voir section sur l’héritage). C’est dans ce template que les Messages Flash sont affichés.
•.main.html : affichage de la page d’accueil (utilise le template de base).
•.edit_nom.html : affiche la page d’édition d’un nom. Ce template est associé à la route /edit/name et lance une requête POST des données sur la route /save/name.
•.edit_message.html : exemple plus complet permettant d’éditer jusqu’à trois Messages Flash affichés par le « template de base ». Ce template est associé à la route /edit/message et envoie avec POST des données sur la route /save/message.
Les vues
Le fichier views.py gère les différentes vues, dont l’accueil sur l’URL racine. Cette page donne accès à deux autres pages qui produiront des Messages Flash qui seront alors visibles au retour sur la page principale.
Voici la première partie du fichier qui démontre l’usage élémentaire des Messages Flash.
01: # coding: utf8
02: from app import app
03: from flask import render_template, request, \
04: redirect, url_for, flash
05:
06: app.secret_key = ’la_cle_secrete’
07:
08: @app.route(’/’)
09: def main( nom = None ):
10: return render_template( ’main.html’ )
11:
12: @app.route(’/edit/name’)
13: def edit_name():
14: return render_template( ’edit_nom.html’ )
15:
16: @app.route(’/save/name’, methods=[’POST’] )
17: def save_name():
18: if request.form[’act’] == ’Abandonner’:
19: flash( u’Opération abandonnée’, ’error’ )
20: else:
21: # Sauvegarder les données
22:
23: flash( u’Nom "%s" enregistré’ % request.form[’name’] )
24:
25: return redirect( url_for( ’main’ ) )
...
•.Ligne 6 : les Messages Flash utilisent les cookies, il est donc nécessaire de définir une clé secrète.
•.Lignes 8 à 10 : rendu de la page d’accueil (utilise le « template de base » base.html).
•.Lignes 12 à 14 : rendu de la page de saisie d’un nom.
•.Lignes 16 à 17 : capture de la route /save/name normalement utilisée pour sauvegarder le contenu du formulaire et renvoyer des Messages Flash de confirmation.
•.Lignes 18 à 19 : si le bouton Abandonner a été pressé, alors indiquer le message Opération abandonnée en stipulant la catégorie « error ».
•.Lignes 20 à 23 : sinon, c’est le bouton Envoyer qui a été pressé et un message de confirmation est envoyé. Dans ce cas, la catégorie par défaut « message » sera utilisée pour catégoriser le message.
•.Ligne 25 : renvoi vers la page principale.
Le template de base
Le « template de base » base.html est presque identique à l’exemple développé dans la section traitant de l’héritage Jinja.
La différence réside principalement dans la gestion des Messages Flash.
01: <!DOCTYPE HTML>
02: <html>
03: <header>
04: {% block head %}
05: <link rel="stylesheet" type="text/css"
06: href="{{ url_for( ’static’,
07: filename=’style.css’ ) }}" />
08: <title>{{ title }} - Flash-Message-App</title>
09: {% endblock %}
10: </header>
11: <body>
12: <h1>{{ title }}</h1>
13 {# Affichage des Message Flash #}
14: {% with messages =
15: get_flashed_messages(with_categories=true) %}
16: {% if messages %}
17: <ul class=flashes>
18: {% for categ, message in messages %}
19: {% if categ == ’error’ %}
20: {% set cls = ’error’ %}
21: {% else %}
22: {% set cls = ’’ %}
23: {% endif %}
24: <li class="{{ cls }}">
25: {% if categ == ’error’ -%}
26: [{{ categ | upper }}]
27: {% endif %} {{ message }}
28: </li>
29: {% endfor %}
30: </ul>
31: {% endif %}
32: {% endwith %}
33: {# Affichage du contenu #}
34: <div id="content">{% block content %}{% endblock %}</div>
35: <div id="footer">
36: {% block footer %}
26: -- La Maison Pythonic @
27: <a href="https://github.com/mchobby/la-maison-pythonic">
28: GitHub</a> --
29: {% endblock %}
30: </div>
31: </body>
32: </html>
•.Lignes 4 à 9 : inclusion de la feuille de style et initialisation de la balise titre.
•.Lignes 14 et 15 : capture des messages disponibles avec get_flashed_messages(). Le paramètre with_categories=true permet d’obtenir l’information de catégorie pour chacun des messages. La fonction retourne une liste de tuples [ (categorie, message), (categorie, message), ...].
La balise {% with %} permet de maintenir la ressource jusqu’à la balise {% endwith %} où celle-ci sera libérée.
•.Ligne 16 : s’il y a des messages disponibles (si messages est différent de None), alors il faut les afficher en exécutant les lignes 17 à 31.
Affichage des messages dans un cadre
•.Ligne 17 : débuter une liste à puces (balise HTML <ul>) afin d’afficher une puce par message (donc une balise <li> par message). La balise <ul> utilise la classe CSS « flashes » pour afficher la liste des messages dans un cadre.
•. Ligne 18 : utilisation d’une balise {% for %} pour parcourir la liste des messages. Chaque message est constitué d’un tuple (categorie,message). La syntaxe {% for categ, message in messages %} permet de récupérer ces deux éléments respectivement dans les variables categ et message.
•.Lignes 19 à 23 : initialisation de la variable Jinja cls qui contiendra la classe CSS utilisée pour afficher le message. Soit une chaîne vide pour un rendu en noir, soit « error » pour un affichage en rouge (et gras).
•.Ligne 24 : création d’une puce avec la balise <li> et la classe d’affichage déterminée par les lignes 19 à 23.
•.Lignes 25 à 27 : ajout du préfixe « [ERROR] » si le message appartient à la catégorie de message « error ».
•.Ligne 27 : affichage du contenu du message avec la balise {{ message }}.
•.Ligne 30 : fin de la liste à puce avec la balise HTML </ul>.
•.Ligne 32 : fin d’insertion des Messages Flash.
•.Ligne 34 : les balises {% block content %}{% endblock content %} permettent d’insérer le contenu de la page définie dans le template enfant.
•.Lignes 36 à 29 : les balises {% block footer %}{% endblock footer %} permettent de définir un pied de page dans le template enfant. La valeur par défaut « La Maison Pythonic ... » est utilisée s’il n’y a pas de redéfinition du bloc.
Page d’accueil
L’affichage de la page d’accueil (sur l’URL racine) est assuré par le fichier main.html.
Ce dernier hérite de base.html et définit les blocs nécessaires.
01: {% extends "base.html" %}
02: {% set title = "Accueil" %}
03: {% block content %}
04: <p>
05: Cette application permet de découvrir les possibilités
06: du Flash Message (<em>Message Flashing</em>). Les boutons ci-
07: dessous conduisent vers des pages qui produisent des Flashs
08:Messages.
09: </p>
10: <input type="button" value="Saisir Nom"
11: onclick="location.href=’{{ url_for("edit_name") }}’;" />
12:
13: <input type="button" value="Saisir Message"
14: onclick="location.href=’{{ url_for("edit_message") }}’;" />
15: {% endblock %}
•.Ligne 1 : le template dérivé de base.html. C’est le « template de base » base.html qui prend en charge l’affichage des Messages Flash.
•.Ligne 2 : définition de la variable titre (utilisée dans le « template de base »).
•.Lignes 3 à 15 : définition du bloc content affichant le contenu de la page d’accueil avec deux boutons, l’un invitant à la saisie d’un nom, l’autre à la saisie de messages.
Contenu de la page d’accueil
Cette même page d’accueil peut également afficher des Messages Flash produits par le traitement d’autres routes.
Page d’accueil avec affichage d’un Message Flash (produit par la route /saisir/name)
Page d’accueil avec affichage de Message Flash (produit par la route /saisir/message ).
Édition du nom
Le template edit_nom.html permet d’afficher la page de saisie.
Ecran d’édition du nom
Le contenu du template edit_nom.html :
01: {% extends "base.html" %}
02: {% set title = "Editer Nom" %}
03: {% block content %}
04: <form action="{{ url_for(’save_name’) }}" method="post">
05: <table>
06: <tr><td>Saisir nom</td>
07: <td><input type="text" name="name"
08: value="Saisir un nom" /></td>
09: </tr>
10: <tr><td colspan="2">
11: <input type="submit" name="act" value="Envoyer" />
12: <input type="submit" name="act" value="Abandonner" />
13: </td></tr>
14: </table>
15: </form>
16: {% endblock %}
•.Ligne 1 : le template dérivé de base.html. C’est le « template de base » base.html qui prend en charge l’affichage des Messages Flash.
•.Ligne 2 : définition de la variable titre (utilisée dans le « template de base »).
•.Lignes 3 à 15 : définition du bloc content affichant le contenu de la page.
•.Ligne 4 : définition du formulaire envoyant les données vers la route /save/nom.
•.Ligne 7 : inclusion d’une zone de saisie de type texte.
•.Lignes 11 et 12 : ajout de deux boutons de soumission Envoyer et Abandonner.
Dans les deux cas, le formulaire est envoyé vers le serveur Flask. La seule différence réside dans la valeur renvoyée pour le champ « act » (qui contient soit « Envoyé », soit « Abandonner »). L’envoi du formulaire vers Flask permet de générer un Message Flash dans les deux cas.
Pour rappel, la route /save/name contient le code de traitement suivant extrait de views.py :
16: @app.route(’/save/name’, methods=[’POST’] )
17: def save_name():
18: if request.form[’act’] == ’Abandonner’:
19: flash( u’Opération abandonnée’, ’error’ )
20: else:
21: # Sauvegarder les données
22:
23: flash( u’Nom "%s" enregistré’ % request.form[’name’] )
24:
25: return redirect( url_for( ’main’ ) )
Le test sur request.form[’act’] permet de détecter quel bouton a été pressé dans le formulaire et d’adapter le Message Flash envoyé. À noter en ligne 19, l’utilisation de la catégorie « error » lors du Message Flash.
Saisie message
Le projet inclut également un second exemple permettant de saisir des messages à flasher. Le template edit_message.html permet de saisir jusqu’à trois messages et éventuellement d’en marquer quelques-uns en catégorie « error ».
Voir le fichier views.py ainsi que les routes /edit/message et /save/message pour plus d’information.
Ces routes permettent de produire les pages suivantes :
Écran de saisie des messages (route /edit/message)
Une fois les messages encodés et le bouton Envoyer pressé, le retour en page d’accueil permet de voir les Messages Flash. Ces Messages Flash ont été communiqués lors de l’appel de /save/message, juste avant la redirection vers la page d’accueil.
Résultat de message encodé sur la page d’accueil
L’écran d’édition contient également un bouton Envoyer+Saisir Nom renvoyant vers l’écran d’édition de nom. Si cela est fonctionnellement inapproprié, ce bouton permet de démontrer la prise en charge des Messages Flash sur toutes les pages produites grâce au « template de base ».
Saisie de messages puis action sur le bouton Envoyer+Saisir Nom
Affichage des Messages Flash partout grâce au template de base
À ce stade, le projet dispose d’objets ESP8266 (sous MicroPython) collectant des données télémétriques envoyées vers le broker MQTT du Raspberry Pi.
Une partie de ces données sont enregistrées dans une base de données SQLite 3 à l’aide du script push-to-db.py.
État du projet
Tout ce qui manque à ce projet, c’est une vitrine pour présenter ces informations de façon attractive ! C’est ce que propose ce chapitre en réalisant un projet Flask exploitant le framework CSS Materialize (materializecss.com).
Application Flash pour afficher des tableaux de bord
Une copie des fichiers nécessaires est disponible sur le dépôt GitHub de l’ouvrage. Le sous-répertoire /python/dashboard/ contient le projet Flask permettant de créer les tableaux de bord présentés dans ce chapitre.
Dépôt GitHub de l’ouvrage
Le répertoire /python/dashboard/install/ contient des ressources destinées à faciliter l’installation et la mise en œuvre du projet Flask. Ce dernier répertoire n’est pas nécessaire pour exécuter le projet.
Une copie des bases de données avec un échantillon de données est également disponible dans le répertoire /python/dashboard/install/demodb/. Cela permettra d’explorer rapidement le projet sans avoir besoin de mettre tous les autres éléments du livre en œuvre pour collecter les premières données.
Le tableau de bord s’organise autour des éléments suivants :
Données MQTT - pyhtonic.db
La base de données pythonic.db contient les données MQTT capturées par le script push-to-db.py développé dans le chapitre Persistance des données.
La base de données doit être installée dans le répertoire /var/local/sqlite/ en suivant les recommandations de configuration du chapitre Persistance des données.
Une copie de la base de données avec un échantillon de données est disponible dans le répertoire /python/dashboard/install/demodb/ du dépôt GitHub.
Données du tableau de bord - dashboard.db
La base de données dashboard.db contient les données de configuration des tableaux de bord et de leur contenu (les différents blocs affichés).
La création et l’installation de la base de données dans le répertoire /var/local/sqlite/ seront développés plus loin dans ce chapitre.
Une copie de la base de données avec un échantillon de données est disponible dans le répertoire /python/dashboard/install/demodb/ du dépôt GitHub.
Tableau de bord
Le tableau de bord est articulé autour d’un projet Flask exploitant l’héritage de template, les macros Jinja, les Messages Flash, la connexion à une base de données tels que décrits dans le chapitre Développement web en Python.
Le projet Flask disponible dans le répertoire /python/dashboard/ du dépôt GitHub propose le sous-répertoire applicatif app contenant les ressources nécessaires comme le framework CSS Materialize, des images, etc. L’application Flask peut être facilement démarrée avec la commande python runapp.py.
Par la suite, naviguer sur l’adresse IP du Raspberry Pi (ou son nom sur le réseau local) pour avoir accès au tableau de bord.
http://pythonic.local:5000
Page d’accueil présentant les différents tableaux de bord disponibles
L’anatomie et le fonctionnement d’un projet Flask est décrit en détail dans le chapitre Développement web en Python.
Fichier de configuration
Le projet Flask utilise un fichier de configuration /etc/pythonic/dashboard.cfg qui est un fichier Python. Ce dernier reprend les sources de données (emplacement des bases de données) et configuration du logger.
Si le fichier de configuration est manquant alors le projet charge le fichier de configuration par défaut dashboard.cfg.default présent dans le répertoire applicatif app.
Le contenu du fichier de configuration sera détaillé plus loin dans le chapitre.
À propos de Materialize
Material Design est un concept initié par Google en 2014 (https://material.io/). Il offre une charte graphique avec des règles de conception permettant de réaliser des interfaces graphiques riches accompagnées d’animations et d’effets de transition.
Dans le concept Material Design, un rendu d’information sur un média numérique est comparé à ce même rendu sur une page de papier. Là où une page de papier adopte un rendu « rigide » et définitif, le média numérique a la possibilité de s’adapter et de réarranger son contenu dynamiquement si les conditions de rendu sont modifiées. Ainsi, une rotation de 90° d’une page de papier (vue en paysage) rend le document illisible alors que la même rotation du média numérique (la tablette) peut permettre le réarrangement dynamiquement du contenu avec la mise en avant d’une information par rapport à l’autre. Ce dynamisme s’applique également lors du redimensionnement de la fenêtre/page (possible sur un média numérique, mais difficile sur une feuille de papier).
Cette brève introduction, qui introduit par ailleurs le concept responsive design (rendu qui s’adapte en fonction de l’espace disponible), peut être complétée en consultant les ressources suivantes :
•.Material Design sur Wikipédia Fr : https://fr.wikipedia.org/wiki/Material_design
•.Material Design : https://material.io/
Materialize est un projet Material Design indépendant assorti d’une documentation et d’exemples permettant de créer rapidement et facilement des interfaces web modernes, réactives et responsives.
Le site Materialize propose d’ailleurs une vitrine de réalisations utilisant leur framework (voir le point Showcase sur la page https://materializecss.com/).
Vitrine de projets réalisés avec Materialize
Parmi les vitrines, « Material Admin Template » propose de nombreuses démonstrations dont « Materialized » qui permet d’explorer en profondeur toute la puissance du framework Materialize. Materialize permet de réaliser des interfaces graphiques très évoluées.
Dans les exemples de la catégorie « Material Admin Template », Materialized est une interface proposant une interface très avancée réalisée à l’aide de Materialize.
Materialized : un des exemples d’interface avancée Materialize
•.Materialized: http://demo.geekslabs.com/materialize-v1.0/app-email.html
•.vitrine « Material Admin Template » : https://pixinvent.com/materialize-material-design-admin-template/landing/
Ressource
•.le site du projet MaterializeCSS : https://materializecss.com/
Il existe également une version « Material Design Lite » qui n’utilise pas de code JavaScript. Ce dernier est optimisé pour une utilisation sur périphériques légers et fonctionne correctement sur des navigateurs plus vieux. Voir le lien suivant pour plus d’informations : https://getmdl.io/index.html
L’application « Tableau de bord » permet de définir une liste de tableaux de bord et leurs contenus (des blocs). Les blocs affichent les informations en provenance d’une base de données Pythonic (données télémétriques/MQTT) dans le tableau de bord.
Le diagramme suivant présente les relations entre les différentes pages disponibles dans l’application « tableau de bord ».
Fonctionnalités générales du tableau de bord
Liste des tableaux de bord
La page d’accueil présente une liste des tableaux de bord (Dashboards) définis dans l’application.
Liste des tableaux de bord
1. | L’icône Home permet, à tout moment, de revenir à la page d’accueil. |
2. | L’icône Configure permet de configurer les paramètres de l’application. Note : le développement est encore à réaliser. |
3. | L’icône + permet d’ajouter un nouvel élément sur la page (un nouveau tableau de bord). |
4. | Cliquer sur une entrée (icône ou libellé) pour accéder au contenu du tableau de bord. Le libellé peut également contenir l’identification d’une page spéciale comme {TOPICS}, {DEMO}. La couleur de fond (et la couleur du texte) est définie lors de la création de l’enregistrement. |
5. | L’icône Edit permet de modifier les caractéristiques du tableau de bord (icône, nom, couleurs). |
6. | L’icône Delete permet d’effacer le tableau de bord de la liste (après confirmation de l’utilisateur). |
Créer/modifier un tableau de bord
La page suivante permet de créer et/ou personnaliser l’en-tête d’un tableau de bord.
Création d’un nouveau tableau de bord
La page de création d’un tableau de bord permet de préciser :
•.Le libellé du tableau de bord qui sera affiché dans la liste. Le libellé peut être l’identification d’une page spéciale comme {TOPICS} qui affiche les topics disponibles (et les dernières valeurs connues).
•.La couleur de fond du tableau de bord (celle reprise dans la liste des tableaux de bord et dans l’en-tête du tableau de bord).
•.La couleur de texte (celle reprise dans la liste des tableaux de bord).
•.L’icône (une icône associée au tableau de bord).
La liste déroulante des couleurs utilise un select Materialize pour afficher les pavés de couleur. Les couleurs sont reprises sous forme d’images disponibles dans /static/images/colors/.
Sélection d’une couleur avec composant select Materialize
La liste déroulante des icônes utilise également un composant select Materialize pour afficher l’icône à sélectionner. Dans l’application, les icônes sont affichées à partir de la font Material+Icons de Google. Par contre, l’affichage des icônes dans la liste nécessite l’emploi d’images (récupérées depuis https://material.io/tools/icons/) et disponibles dans /static/images/icons/.
Sélection d’une icône avec un composant select Materialize
Tableau de bord
L’affichage du tableau de bord (dashboard) reprend différents blocs reproduisant le contenu de messages MQTT enregistrés dans la base de données pythonic.db.
La capture ci-dessous représente le contenu du tableau de bord Maison. La couleur de l’en-tête correspond à celle définie lors de la création du tableau de bord.
Contenu du tableau de bord
1. | Titre du tableau de bord affiché sur le fond de couleur assigné au tableau de bord. |
2. | Icône Configure qui permet d’altérer le contenu du tableau de bord. Cliquer sur cette icône affiche une liste des blocs. |
3. | Icône + qui permet d’ajouter un nouveau bloc sans passer par la liste des blocs. |
4. | Icône Refresh pour recharger immédiatement la page. Notez que la page est rechargée automatiquement toutes les 120 secondes. |
5. | Bloc affichant des informations. Le bloc indiqué par le point 5 est un bloc de type icon. Le titre du bloc indique qu’il s’agit de la température de la véranda où il fait 23,91 °C. Le bloc mentionne également l’âge de l’information, à savoir 12 minutes. |
6. | Icône permettant d’accéder à l’historique des valeurs (lorsque l’information est disponible). |
Liste des blocs
L’icône Configure disponible sur le tableau de bord affiche une liste des blocs du tableau de bord.
Liste des blocs du tableau de bord Maison
Cette page n’affiche pas de grande nouveauté et sert surtout à modifier le contenu du dashboard en permettant d’ajouter, modifier ou effacer des blocs du tableau de bord.
1. | Le titre permet de revenir à l’affichage du tableau de bord. |
2. | L’icône Retour permet de revenir sur la page précédente (donc au tableau de bord). |
Créer/modifier un bloc
La configuration d’un bloc (ajouter/modifier) n’est pas vraiment plus complexe que l’ajout d’un tableau de bord. La différence réside principalement dans la sélection de la source, du topic et du type de bloc.
Écran de modification d’un bloc
Hormis l’icône, la configuration de la couleur de fond et du texte du bloc (point déjà abordé plus haut), cette page permet de configurer les éléments suivants :
1. | Titre du bloc qui est affiché en haut de celui-ci. |
2. | Source de données qui contient les informations à afficher dans le tableau de bord (pythonic.db, la base de données chargée par push-to-db.py). Notez que les sources de données disponibles sont définies dans le fichier de configuration. |
3. | Sélection d’un des topics disponibles dans la source de données. Cette liste est remplie une fois la source de données sélectionnée. |
4. | Type de bloc à afficher. |
L’application Dashboard prévoit plusieurs types de blocs pris en charge par des macros Jinja au moment du rendu.
La sélection du type de bloc se fait à l’aide d’un composant select Materialize qui affiche des logos pour identifier plus facilement le type de bloc. Les logos sont repris sous forme d’images disponibles dans /static/images/block_types/.
Composant select Materialize pour sélectionner le type de bloc
Au moment de la rédaction de cet ouvrage, seuls les blocs icon et big_text sont achevés et pleinement fonctionnels.
La page d’édition de bloc contient également des champs cachés concernant le type d’historique à afficher (et sa longueur). Le seul type d’historique prévu dans l’ouvrage est LIST. Celui-ci affiche la liste des valeurs historiques. Il est tout à fait envisageable de proposer d’autres types d’historiques comme un graphique de l’évolution des valeurs. Dans ce cas, les champs cachés devraient être remplacés par un composant select Materialize.
La page d’édition du bloc est également destinée à recevoir un autre champ (actuellement masqué) nommé block_config. Celui-ci est destiné à recevoir le paramétrage d’un bloc spécifique au format JSON. Ce champ sera mis en œuvre lors de l’implémentation du bloc SWITCH.
Les types de blocs du Dashboard
Le bloc BIG_TEXT affiche le contenu du message en grand dans le bloc.
Bloc BIG_TEXT
Le bloc affiche les informations suivantes :
1. | Affichage du titre du bloc. |
2. | Affichage du message MQTT (sans aucune mise en forme). |
3. | Affichage d’un pied de bloc (footer) avec le délai depuis la dernière mise à jour. |
Le bloc ICON affiche une icône en plus du message.
Bloc ICON
Le bloc affiche les informations suivantes :
1. | Affichage du titre du bloc. |
2. | Affichage de l’icône configurée pour le bloc. |
3. | Affichage du message MQTT (sans aucune mise en forme). |
4. | Affichage d’un pied de bloc (footer) avec le délai depuis la dernière mise à jour. |
Le bloc SWITCH (encore inachevé) affiche un interrupteur.
Bloc SWITCH
1. | Affichage du titre du bloc. |
2. | Une icône symbolisant l’état marche/arrêt. |
3. | Un interrupteur permettant de passer d’un état à l’autre. |
4. | Affichage de la valeur du topic (avec un message complémentaire étant donné que le bloc est en cours de développement). |
5. | Affichage d’un pied de bloc (footer) avec le délai depuis la dernière mise à jour. |
Historique des valeurs
Lorsque la table des valeurs topicmsg de la source de données (pythonic.db) contient une information d’historique (tsname assigné, cf. Persistance des données - Approche base de données de push-to-db), alors le bloc affiche une icône historique en haut à droite du bloc.
Source de données, stockage des messages
Icône historique sur le bloc (la loupe)
Cliquer sur l’icône historique permet d’accéder à l’historique (LIST) des valeurs.
Historique des valeurs
Les pages spéciales
L’application inclut également des pages spéciales qui peuvent être appelées depuis la liste des tableaux de bord en saisissant leurs noms dans le titre du tableau de bord.
Page spéciale | Description |
{TOPICS} | Liste des topics et messages disponibles dans la base de données pythonic.db (ou toute autre base de données renseignée dans le fichier de configuration). |
{DEMO} | Page d’accueil des démonstrations Materialize. Initialement, cette page (et ses sous-pages) était utilisée pour tester l’élaboration de l’interface avec Materialize et le code HTML correspondant. Tester et inspecter le code HTML est riche d’enseignements, raison pour laquelle les pages DEMO restent disponibles. Cette page reprend également des liens vers les ressources icon et colors de Materialize. Cette page est accessible directement par l’intermédiaire de l’URL /demo. |
Liste des topics disponibles avec {TOPICS}
Page de démonstration materialize {DEMO}
Exemple de contenu
La page de démonstration ci-dessus peut être découpée en entités logiques.
Découpage HTML en entités logiques
Le tableau ci-dessous reprend la définition des différentes entités avec leur correspondance dans le code HTML.
Entité | Description | Lignes HTML |
1 | Barre de navigation Contient le titre à gauche et les icônes d’action à droite. | 28 à 40 |
2 | Titre | 30 |
3 | Actions Icônes permettant d’initier une action. | 31 à 38 |
4 | Contenu Bloc contenant le contenu de la page. | 42 à 59 |
5 | Bloc Bloc de type ICON. | 45 à 51 |
6 | Bloc Bloc de type BIG_TEXT avec un footer. | 45 à 57 |
01: <!DOCTYPE html>
02: <html>
03: <head>
04: <!--Import Google Icon Font-->
05: <link
06: href="https://fonts.googleapis.com/icon?family=Material+Icons"
07: rel="stylesheet">
08: <!--Import materialize.css-->
09: <link type="text/css" rel="stylesheet"
10: href="css/materialize.min.css"
11: media="screen,projection"/>
12: <meta charset="UTF-8">
13: <!--Let browser know website is optimized for mobile-->
14: <meta name="viewport"
15: content="width=device-width, initial-scale=1.0"/>
16: <style type="text/css">
17: .blocs{
18: height: 250px;
19: /*background-color: #EFE;*/
20: }
21: .blocs p.foot{
22: font-size: 12px;
23: color:#ccc;
24: }
25: </style>
26: </head>
27: <body>
28: <nav>
29: <div class="nav-wrapper">
30: <a href="#!" class="left">Pythonic</a>
31: <ul class="right">
32: <li><a href="#">
33: <i class="material-icons">lock_outline</i></a>
34: </li>
35: <li><a href="#">
36: <i class="material-icons">import_export</i></a>
37: </li>
38: </ul>
39: </div>
40: </nav>
41:
42: <div class="container">
43: <!-- Page Content goes here -->
44: <div class="row">
45: <div class="center blocs col s12 m4 l3">
46: <h4>Météo</h4>
47: <i class="material-icons"
48: style="font-size:6rem;">wb_sunny</i>
49: <br>
50: <h5>12°C</h5>
51: </div>
52:
53: <div class="center blocs large col s12 m4 l3">
54: <h4>Ext Temp</h4>
55: <h2>28°C</h2><br>
56: <p class="foot">58 seconds ago</p>
57: </div>
58: </div>
59: </div>
60:
61: <!--Import jQuery before materialize.js-->
62: <script type="text/javascript"
63: src="https://code.jquery.com/jquery-3.2.1.min.js">
64: </script>
65: <script type="text/javascript"
66: src="js/materialize.min.js">
67: </script>
68: </body>
69: </html>
Hormis les différentes entités reprises ci-avant, il y a encore quelques points notables :
•.Lignes 5 à 7 : chargement de la feuille de style (et des polices) correspondant aux icônes affichées. Les icônes ne sont pas des ressources graphiques, mais bien des caractères provenant d’une police spéciale.
•.Lignes 9 à 11 : chargement de la feuille de style Materialize.
•.Lignes 16 à 25 : déclaration d’un style local pour le rendu des blocs.
•.Lignes 31 à 38 : les différentes actions sont reprises dans une liste <ul> avec un élément <li> pour chacune des entrées. Les icônes sont affichées à l’aide de la syntaxe <i class="material-icons">nom_de_l_icone</i>. Une liste des icônes disponibles est accessible sur https://materializecss.com/icons.html.
•.Lignes 42 et 59 : le contenu de la page est affiché dans un <div class="container">. Voir la note ci-dessous sur les grilles Materialize.
•.Lignes 44 et 58 : création d’une ligne (classe row de Materialize) pour l’insertion des blocs de tableau de bord.
•.Lignes 45 à 51 : premier bloc du tableau de bord. Il débute avec <div class="center blocs col s12 m4 l3"> et se termine avec la balise </div> correspondante. La classe col signale qu’il s’agit d’une cellule dans une ligne. La taille de la cellule fait 12, 4 ou 3 colonnes en fonction de la largeur de l’écran (voir la note ci-dessous sur les grilles Materialize). L’affichage du contenu du bloc est centré grâce à la classe center. Pour finir, la classe blocs applique le style développé pour l’application tableau de bord. Le contenu du bloc organise les différents éléments d’un bloc ICON.
•.Lignes 53 à 57 : second bloc du tableau de bord. Le contenu du bloc organise les différents éléments d’un bloc BIG_TEXT.
•.Lignes 62 à 64 : chargement de la bibliothèque JavaScript jQuery utilisée par Materialize. La bibliothèque jQuery doit être chargée avant la bibliothèque Materialize.
•.Ligne 65 à 67 : chargement de la bibliothèque JavaScript Materialize.
Organisation du contenu avec une grille Materialize
Le contenu de la page (<div class="container">) est organisé sous forme de grille, donc avec des lignes (row) et des colonnes (col).
Dans Materialize, chaque ligne fait 12 colonnes de large (toutes les colonnes ayant la même largeur).
Un contenu est donc organisé en plusieurs lignes (<div class="row">) contenant chacune des cellules (<div class="col">) pouvant éventuellement s’étendre sur plusieurs colonnes. Chaque cellule <div> est identifiée par sa classe col.
Dans l’exemple, la cellule <div class="center blocs col s12 m4 l3"> mentionne également d’autres classes. La classe s12 mentionne que sur un petit écran (small) la cellule fera 12 colonnes de large. Sur un smartphone, il y aura donc une cellule par ligne, les autres cellules seront alors renvoyées à la ligne.
Affichage sur un écran étroit
La classe m4 mentionne que sur un écran de taille moyenne (medium), la cellule occupera quatre colonnes. Finalement, la classe l3 (la lettre « L » et le chiffre 3) indique que sur un écran large (large) la cellule occupera trois colonnes.
La classe center justifie le contenu de l’affichage dans la cellule, tandis que la classe blocs spécifie le style propre aux blocs d’informations affichés dans le tableau de bord.
Plus d’informations sur les grilles sont disponibles sur le lien : https://materializecss.com/grid.html
Bloc ICON
Bloc ICON
Il correspond au code HTML suivant dont les différents éléments ont déjà été traités précédemment.
01: <div class="center blocs col s12 m4 l3">
02: <h4>Météo</h4>
03: <i class="material-icons"
04: style="font-size:6rem;">wb_sunny</i>
05: <br>
06: <h5>12°C</h5>
07: </div>
Bloc BIG_TEXT
Bloc BIG_TEXT
Il correspond au code HTML suivant, sans surprise particulière :
01: <div class="center blocs large col s12 m4 l3">
02: <h4>Ext Temp</h4>
03: <h2>28°C</h2><br>
04: <p class="foot">58 seconds ago</p>
05: </div>
Bloc SWITCH
Bloc SWITCH
Il correspond au code HTML suivant :
01: <div class="center blocs col s12 m4 l3">
02: <h4>Lights</h4>
03: <i class="material-icons"
04: style="font-size:6rem;">wb_incandescent</i>
05: <div class="switch">
06: <label>
07: Off
08: <input type="checkbox">
09: <span class="lever"></span>
10: On
11: </label>
12: </div>
13: <p class="foot">57 seconds ago</p>
14: </div>
La particularité réside dans les lignes 5 à 12 créant l’interrupteur.
•.Lignes 6 à 11 : affichage des libellés « Off » et « On » autour de l’interrupteur (le slider).
•.Ligne 8 : champ de type checkbox permettant de récupérer l’information, champ qui doit encore recevoir des attributs complémentaires. Il est possible d’afficher un switch désactivé en utilisant <input disabled type="checkbox">.
•.Ligne 9 : cette ligne affiche l’interrupteur entre les libellés « On » et « Off ».
Plus d’informations sur le switch sont disponibles sur : https://materializecss.com/switches.html.
Materialize permet de réaliser facilement des listes avec icônes (aussi bien à droite qu’à gauche).
Liste Materialize
La liste est affichée dans la zone de contenu (donc dans l’élément <div class="container">).
01: <div class="row">
02: <ul class="collection with-header">
03: <li class="collection-header">
04: <h4>Liste basique</h4>
05: </li>
06: <li class="collection-item">
07: <div>hertz
08: <a href="#!" class="secondary-content">
09: <i class="material-icons">send</i>
10: </a>
11: </div>
12: </li>
13: <li class="collection-item">
14: <div>newton<a href="#!" class="secondary-content">
15: <i class="material-icons">send</i></a></div>
16: </li>
17: <li class="collection-item">
18: <div>pascal<a href="#!" class="secondary-content">
19: <i class="material-icons">send</i></a></div>
20: </li>
21: <li class="collection-item">
22: <div>joule<a href="#!" class="secondary-content">
23: <i class="material-icons">send</i></a></div>
24: </li>
25: </ul>
26: </div>
•.Ligne 1 : la liste doit être contenue dans une ligne (classe row de la grille Materialize).
•.Ligne 2 : toutes les lignes de la liste sont représentées par des éléments <li>, regroupés sous un élément <ul class="collection">. Étant donné que la liste contient un en-tête, l’élément <ul> est complété avec la classe with-header.
•.Lignes 3 à 5 : lignes d’en-tête dans la liste. L’entrée <li> mentionne la classe collection-header. Le texte doté d’une balise <h4> pour placer le texte en titre.
•.Lignes 6 à 12 : ligne de contenu qui débute par une entrée <li> mentionnant la classe collection-item.
•.Ligne 7 : le contenu d’une ligne est placé dans un élément <div> contenant le texte et d’autres éléments.
•.Ligne 8 : ajout d’un hyperlien (classe secondary-content pour justifier à droite) et d’une icône Materialize (l’élément <i class="material-icons">).
Toutes les pages du tableau de bord sont organisées avec les mêmes éléments HTML tels que ceux décrits dans la section précédente.
Découpage HTML en entités logiques
Cette découpe est utilisée pour créer un template de base Jinja. Le graphique ci-dessous et la table qui suit reprennent les différents éléments Jinja placés dans le template de base, éléments à définir dans le template dérivé.
Éléments du template Jinja
Entité | Description et template Jinja |
1 | Icône Home {{ home_icon }} : nom de l’icône Materialize à afficher. Icône home par défaut. {{ home_target }} : URL de destination de l’icône. La racine (/) par défaut. |
2 | Titre {{ title }} : Titre affiché dans la barre de navigation (et la page HTML). <No title> par défaut. {{ title_url }} : URL de destination du titre. #! par défaut. |
3 | Barre de navigation {{ dash_color }} : couleur de fond pour le tableau de bord utilisée comme couleur de fond de la barre de navigation. Aucune couleur par défaut. |
4 | Bloc d’action {% block actions %} : liste des actions de la page. Par défaut, il affiche le contenu de démonstration suivant : <li><a href="#"><i class="material-icons">lock_outline</i> </a></li> <li><a href="#"><i class="material-icons">import_export</i> </a></li> <li><a href="#"><i class="material-icons">build</i> </a></li> <li><a href="#"><i class="material-icons">add</i> </a></li> |
5 | Contenu {% block content %} : contenu à afficher dans la page. Vide par défaut. |
| En-tête HTML Bien que non visible dans la capture d’écran, la balise {% block head %} permet de remplacer l’en-tête de la page HTML. |
| Code JavaScript Le template contient également une balise {% block javascript %} permettant d’injecter du code JavaScript dans la page. |
| JavaScript - onDocumentReady Le template contient également une balise {% block onDocumentReady %} qui permet d’injecter du code JavaScript en fin d’appel de $(document).ready(). |
01:<!DOCTYPE html>
02:<html>
03:<head>
04: {% block head %}
05: <title>{{ title | default("") }} - Pythonic</title>
06: <link
07: href="https://fonts.googleapis.com/icon?family=Material+Icons"
08: rel="stylesheet" />
09: <link type="text/css" rel="stylesheet"
10: href="{{ url_for(’static’ ,
11: filename=’css/materialize.min.css’) }}"
12: media="screen,projection" />
13: <meta charset="UTF-8">
14:
15: <meta name="viewport"
16: content="width=device-width, initial-scale=1.0"/>
17:
18: <style type="text/css">
19: .blocs{ height: 250px; }
20: .blocs p.foot{
21: font-size: 12px;
22: color:#ccc; }
23: </style>
24: {% endblock %}
25:</head>
26:
27:<body>
28: <nav>
29: <div class="nav-wrapper {{ dash_color | default(’’) }}">
30: <ul class="left">
31: <li>
32: <a {% if dash_color == "white" %}class="black-text"
33: {% endif %}
34: href="{{ home_target | default(’/’) }}">
35: <i class="material-icons">
36: {{ home_icon | default(’home’) }}
37: </i>
38: </a>
39: </li>
40: </ul>
41:
42: <a href="{% if title_url %}{{ title_url }}
43: {% else %}#!{% endif %}"
44: class="left">
45: <h5 {% if dash_color == "white" %}
46: class="black-text"{% endif %}>
47: {{ title | default("<no title>") }}
48: </h5>
49: </a>
50:
51: <ul class="right">
52: {% block actions %}
53: <li>
54: <a href="#"><i class="material-icons">lock_outline</i></a>
55: </li>
56: <li>
57: <a href="#"><i class="material-icons">import_export</i></a>
58: </li>
59: <li>
60: <a href="#"><i class="material-icons">build</i></a>
61: </li>
62: <li>
63: <a href="#"><i class="material-icons">add</i></a>
64: </li>
65: {% endblock %}
66: </ul>
67: </div>
68: </nav>
69:
70: <div class="container">
71: {% block content %}{% endblock %}
72: </div>
73:
74: <script type="text/javascript"
75: src="https://code.jquery.com/jquery-3.2.1.min.js">
76: </script>
77: <script type="text/javascript"
78: src="{{ url_for( ’static’,
79: filename=’js/materialize.min.js’) }}">
80: </script>
81:
82: <script type="text/javascript">
83: <!--
84: M.AutoInit();
85: $(document).ready(function(){
86: {#- Needed for html select ui -#}
87: $(’select’).formSelect();
88:
89: {#- Toasting Flash Messages #}
90: {% with messages = get_flashed_messages(
91: with_categories=true) -%}
92: {%- if messages -%}
93: {%- for categ, message in messages -%}
94: {%- if categ == ’error’ -%}
95: {%- set cls = ’red-text’ -%}
96: {%- set msg = ’[ERREUR] ’+ message -%}
97: M.toast({html: "<strong>{{ msg }}</strong>",
98: classes: "{{ cls }}" });
99: {%- else -%}
100: {%- set cls = ’’ -%}
101: {%- set msg = message -%}
102: M.toast({html: "{{ msg }}",
103: classes: "{{ cls }}" });
104: {%- endif -%}
105: {% endfor -%}
106: {%- endif -%}
107: {%- endwith -%}
108: // M.toast({html: ’Flash done’});
109:
110: {#- Custom onDocumentReady Javascrit content -#}
111: {% block onDocumentReady %}{% endblock %}
112: });
113:
114: {% block javascript %}
115: {% endblock %}
116: -->
117: </script>
118:</body>
119:</html>
Dans le code ci-dessus, les éléments remarquables sont les suivants :
•.Lignes 4 à 24 : bloc Jinja nommé head permettant de remplacer ou compléter le header de la page HTML.
•.Ligne 29 : barre de navigation avec spécification de la couleur de fond à l’aide de la variable Jinja dash_color. Le filtre default() permet de fixer la couleur par défaut.
•.Lignes 30 à 39 : affichage de l’icône home dans le coin en haut à gauche. L’icône comme le texte sont affichés en blanc par défaut. La balise {% if … %} en ligne 32 permet de passer le texte en noir si la couleur de fond dash_color est en blanc. La variable de template home_target de la ligne 34 fixe l’URL de destination, URL pointant vers la racine du site avec le filtre default() si home_target n’est pas spécifié.
•.Lignes 42 à 49 : affichage du titre et de l’URL de destination correspondant au titre. La couleur est appliquée au titre de la même façon que pour l’icône home.
•.Lignes 51 à 64 : affichage des icônes en haut à droite de la barre de navigation. Le {% block actions %} permet au template dérivé de définir les icônes à afficher. Les lignes 54 à 64 sont des exemples d’icônes, exemples remplacés par la page dérivée.
•.Lignes 70 à 72 : contenu de l’écran à insérer par la page dérivée.
•.Lignes 74 à 80 : inclusion du code JavaScript utilisé par Materialize.
•.Ligne 84 : l’appel JavaScript M.AutoInit() et $(’select’).formSelect() initialisent les éléments <select> Materialize utilisés dans les écrans d’encodage (ex. : sélection de l’icône ou couleur d’un bloc).
•.Lignes 89 à 108 : affichage des Messages Flash avec les Toast Materialize. Les instructions génèrent un Toast par message en veillant à afficher les erreurs en rouge et préfixées par « [ERREUR] ». Cette section de code insère une série d’appels JavaScript à M.toast( ... ) dans le script JavaScript de la page HTML.
Les Toast Materialize
Les Toasts permettent d’afficher temporairement des messages d’alerte à l’utilisateur. Les Toasts ne sont pas invasifs et s’adaptent à la taille de l’écran. Ils peuvent avoir des styles CSS différents, des formes différentes, du contenu HTML personnalisé, des inclusions de bouton, etc.
Affichage de toast lors de la validation avant sauvegarde
Un Toast peut être affiché avec un appel JavaScript à M.toast(). La fonction reçoit un dictionnaire de paramètres dont l’entrée html contient le message.
M.toast( {html: ’Un message Flash’} );
Le dictionnaire peut également contenir une série de classes CSS pour personnaliser le contenu du Toast. L’exemple suivant affiche le message en rouge.
M.toast( {html: ’Alerte!’ , classes: ’red-text’} );
Le site materializecss.com propose une page d’information sur les Toasts : https://materializecss.com/toasts.html
L’utilisation du template de base est abordée avec le template dash_list.html qui affiche la liste des tableaux de bord produite sur l’URL racine.
Liste des tableaux de bord
Le template dash_list.html est utilisé par la fonction de traitement main().
@app.route(’/’)
def main():
""" Liste des tableaux de bords """
dashdb = get_db( ’db’ )
# Liste des tableaux de bords
rows = dashdb.dashes()
# Info sur le nom de l’application
application = dashdb.application()
return render_template( ’dash_list.html’,
dash_list=rows,
application=application )
La fonction de traitement utilise le template dash_list.html pour construire le rendu de la page. L’héritage de template est exploité avec le template de base base.html pour réaliser le rendu final.
Notez que la fonction de traitement communique deux paramètres au template :
•.dash_list : une liste de tableaux de bord disponibles.
•.application : un enregistrement avec des informations sur la configuration de l’application.
01: {% extends "base.html" %}
02: {% set title = application.label |
03: default( ’Pythonic’, True ) %}
04: {% set dash_color = application.color %}
05: {% block actions %}
06: <li>
07: <a href="{{ url_for(’app_config’) }}">
08: <i class="material-icons">build</i>
09: </a>
10: </li>
11: <li>
12: <a href="{{ url_for(’dashboard_add’) }}">
13: <i class="material-icons">add</i>
14: </a>
15: </li>
16: {% endblock %}
17: {% block content %}
18: <div class="row">
19: {% if dash_list | length <= 0 %}
20: <div class="col s12">
21: <div class="amber lighten-3">
22: <div class="card-content black-text">
23: <span class="card-title">
24: <strong>Truc et astuce</strong>
25: </span>
26: <p>Il n’y a pas encore de dashboard disponible.<br />
27: Cliquer sur l’icone ’+’ en haut à droite pour
28: ajouter un premier dashboard.<br/></p>
29: </div>
30: </div>
31: </div>
32: {% endif %}
33:
34: <ul class="collection with-header">
35: <li class="collection-header"><h4>Dashboards</h4>
36: </li>
37: {% for r in dash_list -%}
38: {% if r[’label’] | special_page %}
39: {% set url = url_for(’special_page’,
40: name=r[’label’] | special_page ) %}
41: {% else %}
42: {% set url = url_for(’dashboard’, id=r[’id’]) %}
43: {% endif %}
44:
45: {% set url_del = url_for(’dashboard_delete’, id=r[’id’]) %}
46: {% set url_edit = url_for(’dashboard_add’, id=r[’id’]) %}
47:
48: {% if not(r[’icon’]) or (r[’icon’] | length <= 0) %}
49: {% set icon = "visibility" %}
50: {% else %}
51: {% set icon = r[’icon’] %}
52: {% endif %}
53:
54: <li class="collection-item {{ r[’color’] }}">
55: <div>
56:
57: <a href="{{ url }}"
58: class="secondary-content left
59: {{ r[’color_text’] | default(’black’, true) }}-text">
60: <i class="material-icons">{{ icon }}</i>
61: </a>
62:
63: <a href="{{ url }}"
64: class="{{ r[’color_text’] | default(’black’, true) }}-text">
65: {{ r[’label’] }}
66: </a>
67:
68: <a href="{{ url_del }}"
69: class="secondary-content {{ r[’color_text’] |
70: default(’black’, true) }}-text">
71: <i class="material-icons">delete</i>
72: </a>
73:
74: <a href="{{ url_edit }}"
75: class="secondary-content {{ r[’color_text’] |
76: default(’black’, true) }}-text">
77: <i class="material-icons">edit</i>
78: </a>
79:
80: </div>
81: </li>
82: {% endfor %}
83: </ul>
84: </div>
85: {% endblock %}
Pour commencer, le template dash_list.html hérite du template base.html en utilisant {% extends "base.html" %}.
Ensuite, le template définit les éléments attendus par le template de base :
•.La variable title qui affiche le titre dans la barre de navigation.
•.La variable dash_color qui fixe la couleur de fond de la barre de navigation.
•.Le bloc actions qui affiche les icônes dans la barre de navigation.
•.Le bloc content qui affiche le contenu de la page.
Plus en détail :
•.Ligne 2 : définition du titre de la fenêtre en utilisant le filtre default() pour fixer une valeur par défaut (y compris si la valeur est None, cf. paramètre True).
•.Lignes 6 à 15 : bloc actions avec définition des éléments <li> et <i> pour insérer les icônes Materialize permettant d’ajouter un tableau de bord et de configurer l’application. La fonction url_for() est utilisée pour définir les attributs href des hyperliens.
•.Lignes 17 et 85 : bloc content définissant le contenu de la page. Contenu inséré dans une ligne <div class="row">.
•.Lignes 19 à 32 : affichage d’un message si la liste des tableaux de bord (dash_list) est vide. Dans l’instruction {% if dash_list | length <= 0 %}, le filtre length permet d’obtenir la longueur de l’objet dash_list.
•.Ligne 37 : pour chacun des enregistrements dans dash_list.
•.Lignes 38 à 43 : si le libellé correspond à une page spéciale, entourée de « {} », alors composer une URL vers une page spéciale (route ’/{<string:name>}’) sinon composer une URL vers un tableau de bord (route ’/dashboard/<int:id>’). À noter que l’instruction {% if %} utilise le filtre personnalisé special_page.
Le filtre Jinja special_page extrait le texte entre des balises « {} » et renvoie la valeur en majuscules. S’il n’y a rien à extraire, alors le filtre retourne None. Par conséquent, le test {% if r[’label’] | special_page %} est vrai si r[’label’] contient une valeur similaire à « {topics} ». Le même test serait faux si r[’label’] contient la valeur « Maison ». Le filtre Jinja personnalisé special_page est défini dans le views.py.
•.Lignes 45 et 46 : définir les URL pour effacer et éditer le tableau de bord dans les variables Jinja url_del et url_edit.
•.Lignes 48 à 52 : définir la variable Jinja icon correspondant au tableau de bord. Le test permet de définir l’icône par défaut « visibility » si celle-ci est manquante.
•.Lignes 54-55 et 80-81 : ajout d’une ligne dans la liste pour le tableau de bord (r) en appliquant la couleur de fond définie pour ce tableau de bord.
•.Lignes 57 à 66 : icône et libellé du tableau de bord avec leur hyperlien pour afficher le contenu du tableau de bord.
•.Lignes 68 à 72 : icône « poubelle » permettant d’effacer un tableau de bord.
•.Lignes 74 à 78 : icône « edit » affichée à gauche de l’icône « poubelle ».
Les configurations des tableaux de bord sont stockées dans une base de données SQLite nommée dashboard.db.
Schéma de la base de données dashboard. Réalisé avec draw.io
Table application
Cette table, destinée à ne contenir qu’un seul enregistrement, définit des informations propres à l’application comme le libellé sur l’écran d’accueil.
•.id : identifiant unique de l’enregistrement, alias sur rowid et donc auto-incrémenté.
•.label : libellé de l’application affiché dans l’écran d’accueil.
Table dashes
Cette table contient une liste des tableaux de bord disponibles dans l’application.
•.id : identifiant unique de l’enregistrement, alias sur rowid et donc auto-incrémenté.
•.label : libellé du tableau de bord.
•.icon : nom de l’icône Materialize à afficher pour le tableau de bord.
•.color : couleur de fond du tableau de bord. Contient un code couleur CSS Materialize (red, green, blue, purple...). Couleur de fond utilisée sur la ligne de la liste du tableau de bord et couleur de fond de la barre de navigation du tableau de bord.
•.color_text : couleur du texte utilisée sur la ligne de la liste des tableaux de bord.
Table dash_blocks
Cette table contient la définition des blocs affichés dans les différents tableaux de bord.
•.id : identifiant unique de l’enregistrement, alias sur rowid et donc auto-incrémenté.
•.dash_id : tableau de bord concerné (correspond à dashes.id).
•.title : titre affiché dans le bloc.
•.icon : nom de l’icône Materialize à afficher dans le bloc (si applicable).
•.color : couleur de fond du bloc. Code couleur CSS Materialize (Red, Green...) voir color ci-dessus.
•.color_text : couleur du texte affiché dans le bloc.
•.block_type : type de bloc à afficher. Soit ICON, BIG_TEXT, SWITCH ou une autre valeur définie dans la variable Jinja block_types (voir fichier /python/dashboard/app/templates/macro/block.macro).
•.block_config : paramètres de configuration optionnels du bloc, au format JSON (voir l’implémentation du bloc SWITCH pour plus d’informations concernant block_config).
•.source : source des données du bloc, soit une des valeurs mentionnées dans la variable data_sources du fichier de configuration. Voir le fichier /python/dashboard/app/dashboard.cfg.default pour exemple. Contient l’entrée db_pythonic par défaut.
•.topic : identification du topic à afficher dans le bloc. Correspond au champ topic de la table topicmsg dans la base de données indiquée par source.
•.hist_type : type d’historique à afficher, LIST par défaut. Historique affichable uniquement si le topic dispose d’un historique (voir le champ tsname) !
•.hist_size : nombre d’enregistrements à collecter pour afficher l’historique.
Le graphe ci-dessous indique comment le fichier de configuration est exploité pour accéder à la base de données contenant les données MQTT enregistrées par push-to-db.py.
Extraction des messages MQTT
Comme pour le script push-to-db.py, la base de données est stockée dans le répertoire /var/local/sqlite/ en appliquant la configuration des droits permettant l’accès à l’utilisateur pi et au gestionnaire de service. Voir le script /python/dashboard/install/setup.sh dans le dépôt GitHub du projet et les explications détaillées concernant le « script d’installation » de push-to-db.py (cf. Persistance des données - Configuration de push-to-db). Dans les deux cas, la configuration se déroule de façon identique.
Les tables sont créées à l’aide du script SQL createdb.sql détaillé ci-dessous. Le script est également disponible sur le dépôt GitHub du projet dans le fichier /python/dashboard/install/createdb.sql.
create table application (
id integer primary key,
label text not null
);
create table dashes (
id integer primary key,
label text not null,
icon text,
color text not null,
color_text text
);
create table dash_blocks (
id integer primary key,
dash_id integer not null,
title text not null,
icon text,
color text,
color_text text,
block_type text not null,
block_config text,
source text not null,
topic text not null,
hist_type text,
hist_size integer,
FOREIGN KEY (dash_id) REFERENCES dashes(id)
);
L’utilitaire sqlite3 est utilisé pour créer la base de données. Si SQLite 3 n’est pas encore disponible, il peut être installé avec la commande sudo apt-get install sqlite.
Par la suite, la base de données peut être créée à l’aide de la commande :
pi@pythonic:~ $ cat createdb.sql | sqlite3 dashboard.db
Avant d’être déplacée dans le répertoire /var/local/sqlite.
Notez que la création et l’accès à la base de données dans le répertoire /var/local/sqlite nécessitent une configuration de droit particulière. Ce point est détaillé dans le script d’installation setup.sh.
Une copie de démonstration de la base de données est disponible sur le dépôt GitHub du projet dans le répertoire /python/dashboard/install/demodb/.
Dans le cadre d’un projet Flask, le fichier de configuration doit être un fichier Python.
Le projet dashboard utilise également un fichier de configuration Python, mais avec un mécanisme de chargement dynamique :
1. | Charger le fichier /etc/pythonic/dashboard.cfg s’il est présent. |
2. | Sinon le fichier de configuration par défaut sera chargé depuis le répertoire de l’application (soit le fichier app/dashboard.cfg.default). |
Le fichier de configuration est constitué comme suit :
01: # coding: utf8
02: import sys
03: db=’/var/local/sqlite/dashboard.db’
04: db_class=’DashboardDB’
05:
06: # Sources - les sources de données
07: data_sources = [ ’db_pythonic’ ]
08:
09: # Accès aux topics collectés par push-to-db.py
10: db_pythonic=’/var/local/sqlite/pythonic.db’
11: db_pythonic_class = ’PythonicDB’
12:
13: # Temps de rafraîchissement en seconde
14: refresh_time=120
15:
16: SECRET_KEY = b’dezaijéàç6848eankeazopfr)àei’
17:
18: # Config pour capturer tous les logs
19: logger_config = {
20: ’version’: 1,
21: ’formatters’: {
22: ’default’: {
23: ’format’: ’[%(asctime)s] x %(levelname)s in %(module)s: %(message)s’
24: }
25: },
26: ’handlers’: {
27: ’wsgi’: {
28: ’class’: ’logging.StreamHandler’,
29: ’stream’: sys.stdout,
30: ’formatter’: ’default’
31: }
32: },
33: ’loggers’ : {
34: ’root’: {
35: ’level’: ’DEBUG’,
36: ’handlers’: [’wsgi’]
37: }
38: }
39: }
•.Ligne 3 : variable db avec l’emplacement de la base de données de configuration des tableaux de bord. La clé db peut être utilisée pour obtenir la connexion sur la base de données avec la fonction get_db().
•.Ligne 4 : classe que get_db() doit instancier pour offrir les services d’acquisition de données sur la base de données « db ».
•.Ligne 7 : liste des sources de données disponibles (les bases de données pythonic). Pour chacune des entrées, il doit y avoir une variable du même nom indiquant l’emplacement de la base de données et une variable avec le suffixe « _class » indiquant la classe à instancier pour accéder à la base de données. Chacune des entrées peut être utilisée comme clé pour obtenir une connexion vers la base de données correspondante avec la fonction get_db(). Selon data_sources, il doit donc y avoir des variables db_pythonic et db_pythonic_class dans le fichier de configuration.
•.Ligne 10 : variable db_pythonic avec l’emplacement de la base de données pythonic (celle chargée par le script push-to-db.py).
•.Ligne 11 : variable db_pythonic_class indiquant la classe que get_db() doit instancier pour offrir les services d’acquisition de données sur la base de données db_pythonic.
•.Ligne 14 : temps de rafraîchissement, en secondes, utilisé pour forcer le rafraîchissement automatique du tableau de bord affiché.
•.Ligne 16 : la SECRET_KEY préconfigurée pour utiliser les sessions.
•.Lignes 19 à 39 : configuration du logger racine (root) capturant tous les messages à partir du niveau DEBUG. Les messages sont envoyés vers la console (sys.stdout), ce qui permet également de les consulter lorsque le projet est utilisé comme service puisque systemd capture également ces messages.
Chargement dynamique du fichier de configuration
Le chargement dynamique du fichier de configuration est pris en charge par le fichier __init__.py et plus précisément avec le code suivant :
01: # coding: utf8
02: # Importer la bibliothèque Flask
03: from flask import Flask
04: from logging.config import dictConfig
05: import os.path
06:
07: # Initialise l’application Flask
08: app = Flask( __name__ )
09: configuration = None
10:
11: CONFIG_FILE = ’/etc/pythonic/dashboard.cfg’
12: if os.path.exists( CONFIG_FILE ):
13: app.config.from_pyfile( CONFIG_FILE )
14: print( ’config loaded from %s’ % CONFIG_FILE)
15: # Chargement de la configuration en mémoire
16: import imp
17: configuration = imp.load_source( ’configuration’,
18: CONFIG_FILE )
19: else:
20: # Charger le fichier par défaut
21: print( ’loading config from dashboard.cfg.default’)
22: # appliquer la configuration à l’application Flask
23 app.config.from_pyfile( ’dashboard.cfg.default’ )
24: # charge la configuration en mémoire
25: # (chargement relatif a runapp.py)
26: import imp
27: configuration = imp.load_source( ’configuration’,
28: ’app/dashboard.cfg.default’ )
29:
30: # Appliquer la configuration du logger
31: dictConfig( configuration.logger_config )
•.Ligne 8 : initialisation de l’application Flask.
•.Ligne 9 : référence vers le futur module Python configuration qui n’est pas encore chargé.
•.Ligne 12 : si le fichier de configuration /etc/pythonic/dashboard.cfg existe, alors charger ce dernier (sinon passer aux lignes 20 à 28 pour charger le fichier par défaut).
•.Ligne 13 : initialiser les paramètres applicatifs de Flask (la SECRET_KEY par exemple).
•.Lignes 16 et 17 : chargement du fichier de configuration en tant que module et récupération de la référence du module dans la variable configuration. Cela permet ensuite d’écrire le code from app import configuration pour disposer des variables du fichier de configuration. Par exemple : print( configuration.db_pythonic ).
•.Ligne 31 : initialisation de la configuration du logger depuis le dictionnaire logger_config défini dans le fichier de configuration.
dashboard
├── app
│ ├── __init__.py
│ ├── dashboard.cfg.default
│ ├── models.py
│ ├── views_demo.py
│ ├── views_history.py
│ ├── views.py
│ ├── views_special.py
│ ├── static
│ │ ├── css
│ │ │ ├── materialize.css
│ │ │ └── materialize.min.css
│ │ ├── fonts
│ │ │ └── roboto
│ │ ├── images
│ │ │ ├── block_types
│ │ │ │ ├── big_text.png
│ │ │ │ ├── icon.png
│ │ │ │ └── switch.png
│ │ │ ├── colors
│ │ │ │ ├── amber.png
│ │ │ │ ├── ...
│ │ │ │ └── yellow.png
│ │ │ └── icons
│ │ │ ├── M
│ │ │ │ ├── accessibility.png
│ │ │ │ ├── ...
│ │ │ │ └── wb_sunny.png
│ │ │ └── readme.md
│ │ └── js
│ │ ├── materialize.js
│ │ └── materialize.min.js
│ └── templates
│ ├── base.html
│ ├── block_del_confirm.html
│ ├── block_edit.html
│ ├── block_list.html
│ ├── dashboard.html
│ ├── dash_del_confirm.html
│ ├── dash_edit.html
│ ├── dash_list.html
│ ├── demo
│ │ ├── demo_base.html
│ │ ├── demo_form.html
│ │ ├── demo.html
│ │ ├── demo_list.html
│ │ └── demo_main.html
│ ├── history
│ │ └── list.html
│ ├── macro
│ │ ├── block.macro
│ │ └── M_input.macro
│ └── special
│ └── topic_list.html
├── install
│ ├── createdb.sql
│ ├── dashboard.service.sample
│ ├── demodb
│ │ ├── dashboard.db
│ │ └── pythonic.db
│ └── setup.sh
└── runapp.py
•.Répertoire dashboard : répertoire racine du projet Dashboard contenant les diverses ressources du projet.
Le premier niveau du projet Dashboard est scindé en trois sous-répertoires :
•.Répertoire app destiné au développement avec Flask du projet Dashboard.
•.Répertoire install qui contient les ressources pour faciliter l’installation de l’application dashboard.
•.Fichier runapp.py : permet de démarrer facilement l’application dashboard.
Sous-répertoire install
Ce sous-répertoire contient des éléments permettant de faciliter la mise en place du projet.
•.Fichier createdb.sql : permet de créer la base de données dashboard.db avec la commande cat createdb.sql | sqlite3 dashboard.db. Fichier qu’il faudra placer dans le répertoire /var/local/sqlite/.
•.Fichier setup.sh : effectue l’installation des différents éléments dans leurs emplacements respectifs, la création de la base de données et l’assignation des droits appropriés sur les différents éléments. Voir les détails du chapitre concernant la persistance des données.
•.Fichier dashboard.service.sample : fichier de configuration pour exécuter le projet en tant que service sur le Raspberry Pi. Voir le chapitre sur la persistance des données pour savoir comment utiliser un tel fichier à la section Service systemd pour push-to-db.
•.Fichiers demodb/dashboard.db et demodb/pythonic.db : ce sont deux bases de données d’exemple incluant un ensemble de données de démonstration. Ces fichiers doivent se trouver dans /var/local/sqlite (avec les droits appropriés, voir le fichier setup.sh).
Sous-répertoire app
Ce sous-répertoire contient les éléments de l’application Flask ainsi que les diverses ressources.
•.Fichier __init__.py : fichier d’initialisation du paquet. Il charge le fichier de configuration, les différents fichiers views<xxx>.py (prise en charge des routes) et le fichier models.py (accès aux bases de données).
•.Fichier dashboard.cfg.default : fichier de configuration par défaut (fichier Python) chargé si le fichier /etc/pythonic/dashboard.cfg n’est pas disponible.
•.Fichier models.py : permet d’accéder aux bases de données (dashboard.db et pythonic.db) par l’intermédiaire de classes spécialisées. Le fichier models.py contient la fonction get_db() et différentes méthodes permettant de collecter les éléments de configuration et les données MQTT stockées dans pythonic.db.
Les fichiers views.py
Un fichier views.py contient la définition des routes et les fonctions de traitement correspondantes. Dans cette application Flask, les routes ont été réparties dans plusieurs fichiers views en fonction du domaine d’application.
•.Fichier views.py : définit toutes les routes et fonctions de traitement des tableaux de bord. Cela concerne la liste des tableaux de bord (ajouter, effacer, modifier) et de leur contenu (manipulation des blocs).
•.Fichier views_history.py : définit toutes les routes et les fonctions de traitement pour le rendu des historiques sous toutes leurs formes (liste ou graphique).
•.Fichier views_special.py : définit toutes les routes et les fonctions de traitement pour le rendu des pages spéciales (par exemple, la liste des topics disponibles).
•.Fichier views_demo.py : définit toutes les routes et les fonctions de traitement pour le rendu des pages de démonstration Materialize.
Le fichier models.py
Le fichier models.py contient toutes les fonctions, les classes et les méthodes permettant d’obtenir les informations depuis les diverses bases de données.
Le fichier models.py est utilisé pour accéder aussi bien à la base de données de configuration (dashboard.py) qu’à la base de données MQTT (pythonic.db).
Le contenu de models.py fera l’objet d’un point spécifique dans ce chapitre.
Les fichiers templates
Le sous-répertoire templates contient les différents templates Jinja utilisés par les fonctions de traitement pour réaliser le rendu des pages. Déjà développé en début de chapitre, l’héritage de template est mis à contribution pour obtenir un rendu uniforme. Ainsi, tous les fichiers de template héritent de base.html.
Comme pour les vues, les éléments sont subdivisés par domaine d’application.
•.Répertoire templates : contient les fichiers nécessaires au rendu des pages relatives aux tableaux de bord (édition, liste des tableaux de bord, contenu du tableau de bord). Ces templates sont utilisés par views.py.
•.Répertoire templates/history : contient les templates nécessaires au rendu des pages d’historiques. Le seul affichage d’historique disponible étant la liste des valeurs, ce répertoire contient un seul template nommé list.html. Il est utilisé par views_history.py.
•.Répertoire templates/special : contient le template nécessaire au rendu des pages spéciales (hormis les pages de démo). Le template topic_list.html permet d’afficher une liste des topics, la valeur et la date d’enregistrement disponibles dans la base de données pythonic.db (celle contenant une copie des messages MQTT). Ce template est utilisé par views_special.py.
•.Répertoire templates/demo : contient les fichiers templates relatifs aux pages de démonstration Materialize. Ces pages furent utilisées pour élaborer la structure des pages du projet Dashboard. Parmi les exemples, il y a une application de l’héritage de template sur plusieurs niveaux étant donné que les pages de démo héritent du fichier demo_base.html héritant lui-même du fichier base.html.
Les fichiers Macro
Le répertoire templates contient également des macros Jinja. Ces dernières sont stockées dans le sous-répertoire templates/macro.
•.Fichier block.macro : contient une définition des types de blocs disponibles (icon, big_text, switch), ainsi qu’une fonction Jinja nommée make_block() capable d’élaborer le rendu du bloc souhaité.
•.Fichier M_input.macro : ce fichier contient des macros Jinja permettant de faire le rendu de composants d’encodage de formulaire Materialize (M_input signifie Materialize_Input). Ces macros concernent principalement les champs de saisie <select> incluant des icônes, comme par exemple select_icon(), select_color() et select_block_type().
Les fichiers statiques
Le répertoire app contient de nombreuses ressources statiques réparties dans différents sous-répertoires.
•.Le répertoire static/css/ : contient les feuilles de styles Materialize.
•.Le répertoire static/fonts/ : contient les polices utilisées par Materialize.
•.Le répertoire static/js/ : contient les fichiers de code JavaScript, y compris aux nécessaires au framework CSS Materialize.
•.Le répertoire static/images/ : contient les images utilisées par l’application (aucune) et des sous-répertoires avec des ressources spécifiques.
•.static/images/block_types/ : contient les images correspondant aux types de blocs disponibles dans le champ <select> de saisie. Voir la définition de la variable block_types dans app/templates/macro/block.macro. Les images sont au format PNG et font 48 pixels de côté.
Icônes block_type pour champs <select> Materialize
•.static/images/colors/ : contient les images correspondant aux couleurs affichées dans le champ <select> des couleurs. Voir la définition de colors dans app/templates/macro/M_input.macro. Les images sont au format PNG et font 48 pixels de côté.
Icône couleurs pour champs <select> Materialize
•.static/images/icons/M/ : contient la représentation des icônes Materialize disponibles dans le champ <select> des icônes. Voir la définition de icons dans app/templates/macro/M_input.macro.
Icônes pour champs <select> Materialize
Il n’a pas été possible d’utiliser une icône Materialize (ex. : <i class="material-icons">wb_sunny</i>) comme image dans un champ <select> Materialize. Il a donc été nécessaire de créer des pictogrammes pour les icônes « pictogramme » afin de pouvoir les afficher dans un champ <select> Materialize. Hormis ce cas de figure, lors du rendu de l’icône dans les templates Jinja, c’est bien la structure <i class="material-icons">nom_icone</i> qui est utilisée.
La gestion des routes est répartie dans plusieurs fichiers « views ». Le fichier views.py prend en charge les routes principales de l’application. Les autres fichiers, views_history.py, views_special.py, views_demo.py, prennent respectivement en charge les pages d’historiques de données, les pages spéciales (ex. : les topics disponibles) et les pages de démo.
Le graphe ci-dessous présente les fonctionnalités principales du tableau de bord et la définition des routes correspondantes. Cela concerne les fichiers views.py et views_history.py (affichage d’historique).
Fonctionnalités et routes correspondantes
L’accès aux données est fourni par models.py et la fonction get_db().
Selon les préceptes d’implémentation standard abordés dans le chapitre Développement web en Python, une fonction get_db() ou get_bdd() doit ressembler à ceci :
From flask import g
# retrouver la Base De Données
def get_bdd() :
if ’bdd’ not in g :
g.bdd = connecter_bdd()
return g.bdd
@app.teardown_appcontext
def teardown_app(exception):
bdd = g.pop( ’bdd’, None )
if bdd :
fermer_bdd( bdd )
# D’anciennes implémentations de Flask ne disposent
# pas encore de g.pop(...), il faut alors procéder
# comme suit :
# bdd = g.get( ’bdd’, None )
# if dbb :
# fermer_bdd( bdd )
# del( bdd )
Cependant, cette approche convient uniquement pour une seule base de données. Or, l’application dashboard utilise au moins deux bases de données distinctes. La fonction get_db() acceptera donc un paramètre complémentaire db_key permettant de sélectionner la base de données souhaitée.
Un petit retour sur le contenu du fichier de configuration s’impose avant d’entrer dans les détails de la fonction get_db( db_key ).
Pour rappel, le fichier de configuration contient les éléments suivants où il est possible d’identifier les différentes valeurs de db_key, à savoir ’db’ et ’db_pythonic’ (valeurs pouvant être communiquées à la fonction get_db( db_key )).
01: # coding: utf8
02: import sys
03: db=’/var/local/sqlite/dashboard.db’
04: db_class=’DashboardDB’
05:
06: # Sources - les sources de données
07: data_sources = [ ’db_pythonic’ ]
08:
09: # Accès au topics collectés par push-to-db.py
10: db_pythonic=’/var/local/sqlite/pythonic.db’
11: db_pythonic_class = ’PythonicDB’
Le fichier de configuration mentionne également la classe DBHelper à instancier. La classe DBHelper gère l’accès à la base de données et les méthodes de manipulation de données.
Ainsi, le fichier models.py expose les fonctions get_db(db_key) et get_data_sources() suivantes :
01: # coding: utf8
02: from app import configuration
03: from app import app
04: from flask import g
05: import sqlite3
06: from datetime import datetime
07:
08: class GetDbError( Exception ):
09: pass
10:
11: def get_data_sources():
12: data_sources = getattr( configuration, ’data_sources’ )
13: assert data_sources, \
14: "No data_sources defined in the config file"
15: assert type( data_sources ) == list, \
16: "the config file data_sources must be a list of string"
17: return data_sources
18:
19: def get_db( db_key ):
20: """ Récupère ou construit la classe d’accès
21: à la base de données. Extrait les
22: informations <db_key>=<fichier_db_sqlite3> et
23: <db_key>_class=<Nom_de_classe_a_instancier> """
24:
25: if not ’dbs’ in g:
26: g.dbs = {}
27:
28: if not( db_key in g.dbs ):
29: classname_key = ’%s_class’ % db_key
30:
31: try:
32: db_filename = getattr( configuration, db_key )
33: except:
34: raise GetDbError(
35: ’Missing %s entry in configuration file!’ % db_key )
36:
37: try:
38: classname = getattr( configuration, classname_key )
39: except:
40: raise GetDbError(
41: ’Missing %s entry in configuration file!’ % \
42: classname_key )
43:
44: # obtenir une référence vers la classe
45: try:
46: Class = globals()[ classname ]
47: except:
48: raise GetDbError(
49: ’class %s does not exists!’ % classname )
50: # instancier un objet de la classe
51: db_helper = Class( db_filename )
52: g.dbs[ db_key ] = db_helper
53: else:
54: db_helper = g.dbs[ db_key ]
55:
56: return db_helper
•.Ligne 2 : importation du module configuration depuis le paquet app. Pour rappel, configuration est un module chargé à la volée correspondant soit au fichier /etc/pythonic/dashboard.cfg ou au fichier app/dashboard.cfg.default à défaut du premier. Voir les détails concernant le chargement dynamique du fichier de configuration depuis __init__.py (cf. Configuration dans ce chapitre).
•.Lignes 8 à 9 : définition d’une classe d’exception GetDbError spécifique à la fonction get_db().
•.Ligne 11 : définition de la fonction get_data_sources() dont le but est de retourner une liste des différentes bases de données MQTT à disposition. Dans le cas présent, seule la base de données /var/local/sqlite/pythonic.db est disponible, cette dernière est identifiée par ’db_pythonic’ dans le fichier de configuration.
La fonction get_data_sources() retourne une liste permettant de remplir le champ <select> de la « source de données » lors de l’ajout d’un bloc dans un tableau de bord (cf. Présentation dans ce chapitre).
•.Ligne 12 : récupération de l’attribut data_sources dans le module configuration. Par conséquent, le contenu de la variable data_sources du module configuration est donc la liste [ ’db_pythonic’ ].
•.Lignes 13 à 16 : utilisation d’assertions pour vérifier l’existence de la variable et la présence d’une liste dans celle-ci.
•.Ligne 19 : définition de la fonction get_db(db_key) permettant de récupérer une référence vers un objet DBHelper offrant l’accès à la base de données.
•.Lignes 25 et 26 : vérifie si l’objet g de l’application Flask contient déjà l’attribut dbs. L’attribut dbs est un dictionnaire destiné à maintenir les références vers les objets DBHelper créés pour accéder aux bases de données. Avec toutes les bases de données créées, le dictionnaire devrait contenir {’db_pythonic’: <PythonicDB object at 0x7faca5216810>, ’db’: <DashboardDB object at 0x7faca5216850>}. Si l’attribut dbs est manquant, alors il faut l’initialiser avec un dictionnaire vide.
•.Lignes 28, 53, 54 et 56 : vérifier si le dictionnaire dbs contient déjà une entrée pour la connexion souhaitée (soit ’db’, soit ’db_pythonic’). Si une telle entrée existe dans le dictionnaire, alors la référence du DBHelper est récupérée (lignes 53 et 54) avant d’être retournée par la fonction. Si l’entrée n’existe pas dans le dictionnaire, alors un DBHelper est créé et enregistré dans le dictionnaire par l’exécution des lignes 29 à 52. La ligne 56 retourne alors une référence vers le DBHelper fraîchement créé.
•.Ligne 29 : classname_key contient le nom de l’attribut du module de configuration contenant le nom de la classe DBHelper à créer. Pour key = ’db_pythonic’, cela produira le nom d’attribut ’db_pythonic_class’ dont le nom de la classe à instancier est extrait.
•.Lignes 31 à 42 : extraction des valeurs des attributs db_key et classname_key depuis le module de configuration. Si ces éléments sont manquants, alors cela produit une exception GetDbError ! Pour db_key valant ’db_pythonic’, le code extrait respectivement les valeurs db_filename = ’/var/local/sqlite/pythonic.db’ et classname = ’PythonicDB’ depuis le fichier de configuration.
•.Lignes 45 à 49 : les classes sont reprises comme des variables globales et donc accessibles depuis le dictionnaire retourné par globals(). Retrouver une référence vers la classe à l’aide de l’expression PythonicDB = globals()[ ’PythonicDB’ ] avant de créer une instance de la classe avec obj = PythonicDB( ’/var/local/sqlite/pythonic.db’ ). Le code présent à la ligne 46 récupère la référence vers la classe à instancier. Le « C » majuscule dans le nom de la variable indique qu’il s’agit d’une classe (et non d’un objet, instance de la classe). Le bloc try/except permet de capturer l’erreur si la classe n’existe pas et lève une exception avec un message plus explicite.
•.Ligne 51 : une fois la classe obtenue, créer une instance de la classe en passant le paramètre requis par la routine d’initialisation de ladite classe. Les classes DBHelper sont conçues pour recevoir le nom de fichier de la base de données à ouvrir.
•.Ligne 52 : enregistrement du DBHelper fraîchement créé dans le dictionnaire dbs (lui-même maintenu dans la variable g de l’application Flask).
•.Ligne 56 : dans tous les cas, il y a une instance de DBHelper retournée par la fonction get_db().
Obtenir une référence vers une base de données est aussi simple que :
# Base de données de configuration des
# tableaux de bord.
db_dashboard = get_db( ’db’ )
print( type( db_dashboard )) # affiche <class ’DashboardDB’>
db_mqtt = get_db( ’db_pythonic’ )
print( type( db_dashboard )) # affiche <class ’PythonicDB’>
Notes d’autocritique
Une autocritique s’impose sur les différents éléments abordés dans get_db().
Premièrement, les classes DBHelper que sont PythonicDB et DashboardDB devraient hériter d’un ancêtre commun pour clairement faire état de la parenté des deux classes. En effet, toute modification des paramètres d’initialisation d’une des classes DBHelper impose la même modification dans toutes les autres classes DBHelper. Seul l’héritage permettrait de mettre ce point rapidement en évidence, ce qui peut éviter une fastidieuse session débogage durant la maintenance.
Deuxièmement, le passage du paramètre db_filename restreint le champ d’application aux sources de données constituées par un fichier physique (sans mot de passe, sans login, sans paramètres complémentaires). Il serait plus opportun de passer un dictionnaire contenant des paramètres nommés, ce qui ouvrirait la porte à des paramètres supplémentaires et donc à d’autres sources de données (ex. : une base de données PostgreSql sur un serveur distant).
La fonction get_db() ne retourne pas une référence vers un objet de base de données (une connexion), mais vers un DBHelper.
Ces classes DBHelper que sont DashboardDB et PythonicDB sont conçues de sorte à :
•.gérer la connexion avec la base de données,
•.offrir des méthodes (des services) permettant d’obtenir et de manipuler des données.
Le diagramme de classes suivant indique les services principaux offerts par les classes DashboardDB, permettant de manipuler la configuration des tableaux de bord, et PythonicDB, permettant d’obtenir les données MQTT stockées par push-to-db.py.
Diagramme des classes DBHelper (réalisé avec draw.io)
Comme indiqué dans le diagramme, la connexion Sqlite3 initialise le row_factory à sqlite3.Row, ce qui permet d’accéder aux colonnes en utilisant la notation ligne[’nom_de_colonne’] pour obtenir la valeur d’un champ.
Dans les types retournés, la notation « [ sqlite3.Row ] » correspond à une liste d’objets sqlite3.Row.
Le code ci-dessous reprend la création de la classe PythonicDB.
01: class PythonicDB( object ):
02: """ classe DBHelper pour "" accès facile aux données de
03: push-to-db.py """
04: _db = None
05:
06: def __init__( self, db_filename ):
07: self._db = sqlite3.connect( db_filename )
08: self._db.row_factory = sqlite3.Row
09:
10: def __del__( self ):
11: self.close()
12:
13: def close( self ):
14: if self._db :
15: self._db.close()
16: del(self._db)
17: self._db = None
•.Ligne 6 : constructeur de la classe PythonicDB avec le paramètre db_filename mentionnant le nom du fichier de base de données à ouvrir.
•.Ligne 7 : initialisation de la connexion avec la base de données Sqlite3 et le fichier db_filename à ouvrir.
•.Ligne 8 : modifier le row_factory à utiliser lorsque SQLite retourne des enregistrements.
•.Ligne 10 : __del__() est le destructeur de la classe. Celui-ci clôture la connexion vers la base de données et remet la référence à None par l’intermédiaire de la méthode close().
•.Lignes 13 à 17 : méthode close() qui clôture la connexion avec la base de données et libère celle-ci.
Classe PythonicDB
La classe PythonicDB offre les méthodes suivantes :
topics()
Retourne la liste des topics disponibles dans la base de données. Chaque topic est accompagné du champ tsname indiquant si un historique est disponible (et dans quelle table).
get_values( topic_list )
Retourne une liste d’enregistrements topic, message, tsname, rectime correspondant à la liste des topics passés en paramètres. Si topic_list = None, alors tous les topics sont retournés.
get_history( tsname, topic, from_id = None, _len=50 )
Sélectionne une série d’enregistrements depuis la table d’historique tsname pour le topic mentionné. Les enregistrements sont retournés par ordre descendant d’id (donc le dernier en premier). Le paramètre from_id indique à partir de quel enregistrement il faut débuter la collecte des enregistrements (from_id=None pour le dernier enregistrement). Le paramètre _len indique le nombre d’enregistrements à collecter.
Classe DashboardDB
La classe DashboardDB offre les méthodes permettant de gérer la configuration des tableaux de bord.
application()
Retourne un enregistrement avec les paramètres de l’application stockés dans la base de données.
dashes()
Retourne une liste des tableaux de bord contenant les paramètres de ceux-ci. Le résultat de cette fonction permet de produire le rendu de l’écran d’accueil de l’application.
get_dash( id )
Obtient les paramètres de configuration d’un tableau de bord donné. Cette fonction retourne un enregistrement.
save_dash( **kwarg )
Sauve les paramètres d’un tableau de bord dans la base de données. Voir le détail de save_dash_block() ci-dessous concernant le paramètre **kwarg.
drop_dash( id )
Efface un tableau de bord de la base de données (y compris tous les blocs qu’il contient).
get_dash_blocks( dash_id )
Permet d’obtenir une liste d’enregistrements énumérant tous les blocs (et leurs paramètres) contenus dans le tableau de bord dash_id.
get_dash_block( block_id )
Permet d’obtenir les informations de configuration d’un bloc particulier. Retourne un enregistrement avec toutes les informations du bloc (y compris le dash_id du tableau de bord auquel il est rattaché).
save_dash_block( **kwarg )
Sauve les informations de configuration d’un bloc particulier dans la base de données. Les données sont transmises sous forme de dictionnaire.
L’exemple ci-dessous, extrait de views.py, indique comment récupérer les données depuis un formulaire HTML pour, ensuite, les sauver avec save_dash_block().
data = {
’id’ : ( None if request.form[’id’] == ’’
else int( request.form[’id’] ) ),
’dash_id’ : int( request.form[’dash_id’] ),
’title’ : request.form[’title’] ,
...
’hist_size’ : safe_cast( request.form[’hist_size’], int, 50 )
}
get_db(’db’).save_dash_block( **data )
Avec la formulation **data durant l’appel, le dictionnaire est passé à la fonction comme étant une série de paramètres nommés. La notation save_dash_block( **data ) est équivalente à save_dash_block( id=data[’id’], dash_id=data[’dash_id’], title=data[’title’], ... hist_size=data[’hist_size’] ).
Côté implémentation de la fonction, le paramètre **kwarg permet de récupérer tous les paramètres nommés sous forme de dictionnaire. Cette notation permet de récupérer tous les paramètres qui ne sont pas explicitement repris dans l’en-tête de la fonction. Ainsi, l’implémentation de la fonction def save_dash_block( **kwarg ) permet de récupérer la valeur à l’aide de kwarg[’id’].
drop_dash_block( block_id )
Efface un bloc de la base de données.
La liste des topics disponibles est un exemple simple mettant en lumière l’accès à une autre base de données.
Affichage de la liste des topics disponibles
Desservi par une page spéciale, le contenu de la page est rendu par la route /{<string:name>} dans views_special.py.
01: db = get_db( ’db’ )
02: application = db.application()
03: sources = get_data_sources()
04: # Obtenir la première source de données
05: if len( sources )==0:
06: flash( "Pas de sources renseignée dans le "+
"fichier de configuration", ’error’ )
07: return redirect(url_for(’main’))
08:
09: source_db = get_db( sources[0] )
10: topic_rows = source_db.get_values( topic_list = None )
11: return render_template( ’special/topic_list.html’,
application=application,
source=sources[0],
rows=topic_rows )
•.Ligne 1 : obtention d’une référence vers le DBHelperDashboardDB de la base de données dashboard.db.
•.Ligne 2 : obtention des informations concernant l’application.
•.Ligne 3 : obtention de la liste des sources de données (donc db_pythonic comme stipulé avec data_sources = [ ’db_pythonic’ ] dans le fichier de configuration).
•.Lignes 5 à 7 : affichage d’un Message Flash d’erreur s’il n’y a pas de data_sources défini ! Cela signifie surtout qu’il n’y a pas de base de données MQTT disponible.
•.Ligne 9 : obtention d’une référence vers le DBHelper PythonicDB de la base de données pythonic.db qui stocke les messages MQTT capturés par push-to-db.py. Correspond à source_db = get_db( ’db_pythonic’ ) puisque seul le premier élément dans data_sources est retenu.
•.Ligne 10 : obtention de la liste de tous les topics et de leurs valeurs respectives.
•.Ligne 11 : rendu du template en passant l’objet application, le nom de la source de données (db_pythonic) et la liste des enregistrements à afficher.
L’affichage de l’historique d’un topic est un autre exemple d’utilisation des services du DBHelper PythonicDB permettant l’extraction de l’historique des valeurs.
Accéder à l’historique d’un bloc
Affichage de l’historique
Servi par une page d’historique, le contenu de la page est rendu par la route /dashboard/<int:dash_id>/block/<int:block_id>/history/<int:_from> dans views_history.py.
01: def topic_history( dash_id, block_id, _from=0, _len=None ):
02: dashdb = get_db( ’db’ )
03: dash = dashdb.get_dash( dash_id )
04: block = dashdb.get_dash_block( block_id )
05: topic = block[’topic’]
06: hist_type = block[’hist_type’]
07: hist_size = _len if _len else block[’hist_size’]
08: db_source = get_db( block[’source’] )
09: values = db_source.get_values( [topic] )
10: tsname = values[0][’tsname’]
11: hist_rows = db_source.get_history(
12: tsname=tsname, topic=topic,
13: from_id = None if _from <= 1 else _from,
14: _len=hist_size )
15: if hist_type==’LIST’:
16: return render_template( ’history/list.html’,
17: block=block, dash=dash, rows=hist_rows,
18: _from=_from, _len=hist_size )
19: else:
20: flash( (’Type d’’historique %s non supporté’ % \
21: hist_type).decode(’utf-8’), ’error’ )
22: return redirect( url_for(’dashboard’, id=dash_id) )
Pour rappel, l’accès aux données d’historique depuis un bloc (table dash_block) suit le parcours suivant :
•.Obtention de l’id du bloc dans les paramètres de la requête.
•.Récupération de la source et du topic depuis la table dash_blocks.
•.Récupération de l’enregistrement MQTT du topic (table topicmsg) depuis la base de données source.
•.Extraction du tsname depuis l’enregistrement MQTT du topic.
•.Lecture des entrées d’historique pour le topic depuis la table ts_xx portant le nom indiqué dans tsname.
Accès aux données d’historiques d’un topic
•.Ligne 2 : obtention d’une référence vers la base de données du projet Dashboard (soit dashboard.db).
•.Ligne 3 : lecture des informations du tableau de bord.
•.Ligne 4 : lecture des informations du bloc.
•.Ligne 5 : extraction du topic à afficher par le bloc.
•.Ligne 6 : extraction du type d’historique à afficher pour le bloc (LIST en l’occurrence).
•.Ligne 7 : taille de l’historique à afficher. Il s’agit de la valeur passée en paramètre par la fonction ou, à défaut, de celle mentionnée dans le bloc (50 par défaut).
•.Ligne 8 : obtention d’une référence vers la base de données source (’db_pythonic’), donc la base de données pythonic.db.
•.Ligne 9 : obtention de l’enregistrement topic en réduisant la liste des topics au seul souhaité (celui obtenu en ligne 5, la liste contiendra donc un seul enregistrement). Cette valeur est obtenue depuis la base de données pythonic.db.
•.Ligne 10 : extraction du nom de la table d’historique (tsname) depuis l’enregistrement topic.
•.Ligne 11 : obtention des enregistrements d’historique depuis la table tsname pour le topic sélectionné. Le paramètre _form <= 1 est l’équivalent de None (puisqu’il n’est pas possible de passer un entier négatif en paramètre d’une route).
•.Ligne 16 : rendu du contenu avec le template approprié en passant les ressources nécessaires au template Jinja.
L’affichage d’un tableau de bord par le template dashboard.html est un cas typique d’accès à plusieurs sources de données et de la fusion de celles-ci. Des informations sont collectées aussi bien dans la base de données de configuration (dashboard.db) que dans la base de données des messages MQTT (pythonic.db).
Pour rappel, l’affichage des données dans un tableau de bord suit le flux suivant :
Flux des données pour afficher un tableau de bord
•.dashboard.db : contient les définitions du tableau de bord, des blocs à y afficher et leurs configurations respectives. Cette base de données est accessible via la fonction get_db( ’db’ ) du modèle.
•.pythonic.db : source de données contenant les messages et topics capturés sur le broker MQTT par le script push-to-db.py (cf. Persistance des données - Approches techniques de push-to-db).
Il y a donc deux ensembles de données à traiter pour réaliser le rendu de la page d’un tableau de bord. Identifions-les par MQTT pour pythonic.db et BLOC_CONFIG pour dashboard.db.
Deux approches techniques sont envisageables pour produire le rendu HTML :
•.Option 1 : communiquer séparément les ensembles de données MQTT et BLOC_CONFIG au template.
•.Option 2 : réaliser une structure en fusionnant les ensembles de données MQTT et BLOC_CONFIG avant de les communiquer au template.
Évaluation de l’option 1
Dans la première option, le template doit localiser et récupérer les données dans MQTT pour chacun des éléments de BLOC_CONFIG à afficher. Cela conduit à une itération en deux niveaux dans le template.
Le code du template serait inévitablement plus complexe, plus lent et donc plus difficile à maintenir. Pour rappel, le but d’un template est de réaliser un rendu, pas de réaliser une jointure entre MQTT et BLOC_CONFIG.
Évaluation de l’option 2
Réaliser une fusion des données dans le code Python est plus simple à écrire (pas de balisage Jinja) et plus rapide en termes d’exécution.
Le template Jinja dispose d’une structure alliant « configuration du bloc à afficher » + « données à afficher par ce bloc », ce qui permet au template de se concentrer sur sa tâche, le rendu du contenu.
Il est évident que c’est cette deuxième option qui doit prévaloir.
Classe BlockWithData
La fusion des données est réalisée directement dans la fonction de traitement de la route /dashboard/<int:id> (voir la fonction dashboard() dans views.py).
Le processus de fusion s’appuie sur une classe BlockWithData qui embarque à la fois la définition du bloc et la donnée du bloc.
class BlockWithData( object ):
block = None
block_data = None
block_config = None
def __init__( self, block, block_data, block_config ):
self.block = block
self.block_data = block_data
self.block_config = block_config
def __repr__( self ):
return ’<block: %r, block_data: %r>’ % (self.block, self.block_data)
Sans surprise, la classe embarque deux membres qui sont :
•.block : pour accéder aux données de configuration du bloc. Donc un enregistrement (Sqlite3.Row) de la table dash_blocks (située dans dashboard.db) et qui propose les colonnes suivantes.
Définition d’un bloc
•.block_data : pour accéder aux données correspondant au bloc et donc au message MQTT, rectime (heure d’enregistrement), qos (qualité de service), tsname (table d’historique si c’est applicable), etc. Les données de block_data se présentent sous la forme d’un dictionnaire ayant la définition suivante :
{’id’: block_id,
’topic’:_row[’topic’],
’value’:_row[’message’],
’history’:_row[’tsname’],
’rectime’: db.str_to_datetime( _row[’rectime’] ),
’tsname’ :_row[’tsname’] }
L’utilisation d’un dictionnaire pour le membre block_data permet de maintenir une syntaxe identique pour accéder aux données de block et aux données de block_data.
•.block_config : le champ block_config de la table dash_blocks est destiné à recevoir des paramètres de configuration pour le bloc. Ces paramètres sont encodés dans un champ texte en suivant la structure d’un dictionnaire JSON. Afin de faciliter l’accès à ces informations, la présente propriété block_config charge les informations à l’aide de json.load( self.block[’block_config’] ) de sorte que l’information block_config soit disponible sous forme de structure Python ! Les éléments de cette structure Python (miroir des informations JSON) sera plus facile à exploiter dans un template Jinja.
Le tableau de bord est rendu avec le template Jinja en utilisant une liste de BlockWithData, liste que l’on peut représenter comme suit block_with_data_list = [ <BlockWithData >, <BlockWithData >, <BlockWithData > ].
Ainsi, si _bloc = block_with_data_list[0], il est possible d’accéder aux informations en suivant la syntaxe mentionnée ci-dessous, syntaxe également utilisable dans le template.
titre = _bloc.block[’title’]
couleurfond = _bloc.block[’color’]
couleurtexte = _bloc.block[’color_text’]
icon = _bloc.block[’icon’]
topic = _bloc.block[’topic’] # topic a afficher
message = _bloc.block_data[’value’]
heure_enregistrement = _bloc.block_data[’rectime’]
table_d_historique = _bloc.block_data[’tsname’] # ou ’history’
La fonction de traitement
La fonction de traitement dashboard() contient le code suivant :
01: @app.route(’/dashboard/<int:id>’)
02: def dashboard( id ):
03: def distinct( lst ):
04: r = []
05: for item in lst:
06: if item in r:
07: continue
08: r.append( item )
09: return r
10:
11: class BlockWithData( object ):
12: block = None
13: block_data = None
14: block_config = None
15: def __init__( self, block, block_data, block_config ):
16: self.block = block
17: self.block_data = block_data
18: self.block_config = block_config
19: def __repr__( self ):
20: return ’<block: %r, block_data: %r>’ % \
21: (self.block, self.block_data)
22:
23: db = get_db(’db’)
24: application = db.application()
25: dashboard = db.get_dash( id )
26: block_list = db.get_dash_blocks( id )
27:
28: _source_topics = \
29: [ (block[’id’], block[’source’], block[’topic’]) \
30: for block in block_list ]
31: _block_data = {}
32: for source in distinct([ itm[1] for itm in _source_topics ]):
33: _topics = []
34: for id, source, topic in \
35: [ _source_topic for _source_topic in \
36: _source_topics if _source_topic[1] == source ]:
37: _topics.append( topic )
38: source_db = get_db( source )
39: _rows = source_db.get_values( _topics )
40: for _row in _rows:
41: block_ids = \
42: [ source_topic[0] for source_topic in \
43: _source_topics \
44: if source_topic[2] == _row[’topic’] ]
45: for block_id in block_ids:
46: _block_data[block_id] = {’id’: block_id,
47: ’topic’:_row[’topic’],
48: ’value’:_row[’message’],
49: ’history’:_row[’tsname’],
50: ’rectime’: \
51: db.str_to_datetime( _row[’rectime’] ),
52: ’tsname’ :_row[’tsname’] }
53:
54: block_with_data_list = []
55: for row in block_list:
56: try:
57: _block_config = {} if \
58: row[’block_config’]==None or \
59: len( row[’block_config’] )==0 \
60: else json.loads( row[’block_config’] )
61: except Exception as err:
62: app.logger.error(
63: ’Failed to convert JSON block_config for block %s ’+
64: ’to Python structure.’ % row[’id’] )
65: app.logger.error( ’due to error %s on %s’ %
66: (err,row[’block_config’]) )
67: _block_config = { ’_error’ :
68: [’unable to convert json block_config for block %s ’+
69: ’to python structure’% row[id],
70: ’due to error %s on %s’ % (err,row[’block_config’]) ]
71: }
72: block_with_data_list.append(
73: BlockWithData(
74: block=row,
75: block_data=_block_data[row[’id’]],
76: block_config=_block_config
76: )
77: )
78:
79: return render_template( ’dashboard.html’,
80: block_with_data_list = block_with_data_list,
81: dashboard = dashboard,
82: application= application,
83: configuration=configuration )
•.Lignes 1 et 2 : définition de la route et de la fonction de traitement affichant le tableau de bord.
•.Lignes 3 à 9 : définition de la fonction distinct() permettant d’extraire une sous-liste ne contenant qu’une et une seule fois chaque élément. Par exemple, distinct( [’a’, ’b’, ’a’, ’c’] ) → [’a’, ’b’, ’c’].
•.Lignes 11 à 21 : définition de la classe BlockWithData, élément déjà abordé ci-dessus.
•.Ligne 23 : obtention d’une référence sur la base de données dashboard.db.
•.Lignes 24 à 26 : obtention des informations sur l’application, le tableau de bord (dashboard) et la liste des blocs du tableau de bord (block_list).
•.Lignes 28 à 30 : utilisation d’une comprehension list pour collecter les différents source+topic des blocs à afficher dans le tableau de bord. La variable _source_topic contient donc une liste de tuples ! (_source_topic = [ (id,source, topic), (id,source,topic), ... ]) où source est la base de données source pour collecter les données, par exemple ’db_pythonic’.
•.Ligne 31 : création du dictionnaire _block_data destiné à recevoir les données MQTT pour un bloc. À chaque bloc correspond un dictionnaire de données tel que _block_data[ block_id ] = {’id’: block_id, ’topic’: le_topic_mqtt, ’value’: le_message_mqtt, ’history’: valeur_de_tsname , ’rectime’ : heure_d_enregistrement, ’tsname’ : valeur_de_tsname }.
•.Ligne 32 : réaliser une itération (lignes 33 à 52) pour chacune des sources. La compréhension list[ itm[1] for itm in _source_topics ] retourne une liste avec toutes les identifications des sources. La fonction distinct() réduit la liste aux noms distincts. En l’état du projet, l’itération se réduit à la liste [ ’db_pythonic’ ].
•.Lignes 33 à 37 : collecter tous les topics MQTT correspondant à la source. La variable _topics contient une liste de tous les topics à afficher dans le tableau de bord et dont l’information doit être collectée depuis la base de données source.
•.Ligne 38 : obtention d’une connexion vers la base de données source.
•.Ligne 39 : récupération des enregistrements de données (_rows, avec les valeurs MQTT) pour les différents topics contenus dans la liste _topics.
•.Lignes 40 et 44 : pour chacun des enregistrements de _rows (donc pour chaque message MQTT collecté), il faut identifier les blocs (block_id) auxquels il faudra associer le message MQTT. À noter qu’un message MQTT peut être affiché plusieurs fois dans un tableau de bord (dans plusieurs blocs).
•.Lignes 45 à 52 : pour chacun des block_id identifiés, créer le dictionnaire de données à partir du message MQTT avec la structure adéquate {’id’: ..., ’topic’: ..., ’value’: ..., ’history’: ..., ’rectime’: ..., ’tsname’: ... }. Les structures de données sont stockées dans le dictionnaire _block_data en utilisant le block_id comme clé.
•.Ligne 54 : création de la liste block_with_data_list destinée à recevoir les instances de BlockWithData. C’est cette liste qui sera communiquée au template.
•.Lignes 55 à 78 : pour chacun des enregistrements (chacun des blocs), créer un objet BlockWithData avec les informations du bloc (block=), les données du bloc (blockdata=) extraites du dictionnaire _block_data et bloc_config (block_config=) avec la structure Python extraite de block.block_config (texte au format JSON).
•.Lignes 56 (et 61 à 71) : capture d’erreurs dans le cas du chargement erroné du contenu JSON. Création d’un dictionnaire _block_config comptant les messages d’erreur sous l’entrée « _error ».
•.Ligne 57 : expression ternaire permettant d’initialiser le _block_config avec un dictionnaire vide dans le cas où le champ row[’block_config’] ne contiendrait aucun paramètre. Dans le cas contraire, l’expression ternaire utilise json.loads() pour convertir le contenu JSON en objets Python.
•.Ligne 80 : appel d’un template ’dashboard.html’ en passant la structure block_with_data_list et quelques autres informations.
Le template Jinja
Les détails du rendu réalisé par le template dashboard.html sont abordés dans la section concernant les macros Jinja. En effet, le rendu des blocs d’un tableau de bord s’appuie sur celles-ci.
Le projet Dashboard inclut deux filtres Jinja personnalisés.
Le premier filtre, déjà abordé lors de la description du template de base, est special_page. Le filtre special_page extrait le texte placé entre des balises « {} » et renvoie la valeur en majuscules. S’il n’y a rien à extraire, alors le filtre retourne None.
Le filtre peut s’utiliser dans un template Jinja comme suit :
{% set url = url_for(’special_page’, name=r[’label’] | special_page ) %}
Le second filtre est strftime( format=’hour’, source_type=’datetime’ ) qui permet d’appliquer un format particulier à une donnée de type datetime. Par défaut, le filtre utilise la source_type=’datetime’ lorsque la valeur est un objet datetime mais il peut aussi prendre en charge le source_type=’sqlite_dt’ lorsque la donnée est fournie par le moteur SQLite (donc au format « Année-mois-jour Heure:Minutes:Secondes. millisecondes »). Le format permet de préciser le type de transformation souhaitée. Le paramètre format=’hour’ affiche l’information au format « Heure:Minutes » tandis que format=’full’ utilise le format « jour/mois/année heure:minutes:secondes ». Finalement, format=’elapse’ indique le temps écoulé depuis la date avec le motif « m mois, j jours, h heures, m minutes, s secondes » (en ne précisant que l’information pertinente).
<td>{{ row[’rectime’] | strftime(format="full", source_type="sqlite_dt") }}</td>
<td>{{ row[’rectime’] | strftime( format="elapse", source_type="sqlite_dt" ) }}
</td></tr>
Les filtres personnalisés sont définis dans le fichier views.py et déclarés à l’aide du décorateur @app.template_filter().
01: @app.template_filter(’strftime’)
02: def strftime_filter(value, format=’hour’,
03: source_type=’datetime’):
04: if source_type==’datetime’:
05: if type(value)!=datetime:
06: return "(value is not a datetime!)"
07: elif source_type==’sqlite_dt’:
08: if type(value)!=unicode:
09: return "(value is not unicode!)"
10: try:
11: value = datetime.strptime( value ,
12: ’%Y-%m-%d %H:%M:%S.%f’ )
13: except:
14: return "(%s invalid format!)" % value
15:
16: if format == ’hour’:
17: _format="%H:%M"
18: elif format == ’full’:
19: _format="%d/%m/%Y %H:%M:%S"
20: elif format == ’elapse’:
21: sec = (datetime.now()-value).seconds
22: min = sec / 60
23: sec = sec % 60
24: hour = min / 60
25: min = min % 60
26: day = (datetime.now()-value).days
27: month = day / 30
28: day = day % 30
29: if (min>0) or (hour>0) or (day>0):
30: _lst = []
31: if month>0:
32: _lst.append( "%s mois" % month )
33: if day>0:
34: _lst.append( "%s jour%s" %
35: (day, ("s" if day>0 else "")) )
36: if hour>0:
37: _lst.append( "%s heure%s" %
38: (hour, ("s" if hour>0 else "")) )
39: if min>0:
40: _lst.append( "%s minute%s" %
41: (min, ("s" if min>0 else "")) )
42:
43: return ", ".join( _lst )
44: else:
45: return ’%s secondes’ % sec
56: else:
47: _format = format
48:
49: try:
50: return value.strftime(_format)
51: except Exception as err:
52: app.logger.error( "format_strftime: unable "+
52: "to convert datetime %s with "+
54: "format %s due following error" % \
55: (value, format) )
56: app.logger.exception( err )
57: return "(strftime formatting error!)"
58:
59: @app.template_filter(’special_page’)
60: def special_page_filter(value):
61: if ( value.find(’{’)>=0 ) and ( value.find(’}’)>=0 ):
62: return \
63: value[ value.find(’{’)+1 : value.find(’}’) ] \
64: .upper()
65: else:
66: return None
•.Ligne 1 : décorateur @app.template_filter(’strftime’) permettant de déclarer le filtre strftime, accessible dans le moteur de template Jinja.
•.Ligne 2 : fonction de traitement correspondante. Le premier paramètre value est fourni par le moteur de template, alors que les autres doivent être précisés dans le code du template. À noter que les paramètres format et source_type disposent de valeurs par défaut, appliquées si ces paramètres ne sont pas mentionnés lors de l’appel par le template.
•.Lignes 4 à 6 : si le type attendu est un datetime, alors vérifier que le type de value (fourni par le moteur de template) est identique à celui attendu.
•.Lignes 7 à 14 : si le type est ’sqlite_dt’ alors le type attendu pour value est une chaîne de caractères unicode. Le script essaye ensuite de décoder la date retournée par SQLite, date au format ’%Y-%m-%d %H:%M:%S.%f’ pour obtenir un datetime stocké dans la variable value.
•.Lignes 16 à 47 : traitement du paramètre format afin de détecter le format souhaité pour composer la chaîne finale _format.
•.Lignes 49 à 57 : mise en forme de la date au format souhaité. Capture des erreurs pour la journalisation et renvoi d’un message approprié.
Lorsqu’il y a une erreur, celle-ci est mentionnée clairement dans la valeur retournée par le filtre. Cela permet d’identifier dans la page rendue qu’il y a un problème avec les données transmises au filtre.
•.Ligne 59 : décorateur @app.template_filter(’special_page’) permettant de déclarer le filtre special_page, accessible dans le moteur de template Jinja.
•.Lignes 60 à 61 : le filtre reçoit la valeur value communiquée par le moteur de template (sans paramètres optionnels). Si la valeur contient les caractères « { » et « } », alors il s’agit d’une codification de page spéciale.
•.Ligne 63 : découpage de la chaîne de caractères (slicing) pour ne garder que les éléments entre les accolades et transformation en majuscule. Note : ’abcde’[1:4] produit ’bcd’.
•.Ligne 66 : retourne None si value ne contient pas de code de page spéciale.
L’affichage du tableau de bord s’appuie sur le template dashboard.html. Héritant de la page de base, il définit les différents blocs Jinja dont le bloc content et le bloc onDocumentReady.
Le fonctionnement du template est très basique, il parcourt la collection de block_with_data (préparée par la route ’/dashboard/<int:id>’) pour générer la structure HTML des blocs dans la page.
Pour s’aider dans cette tâche, le template s’appuie sur une série de macros développées dans /template/macro/block.macro. Ces macros prennent en charge l’affichage du contenu de chaque type de bloc.
Affichage du contenu d’un dashboard
Le template est appelé depuis views.py avec l’instruction suivante :
return render_template( ’dashboard.html’,
block_with_data_list = block_with_data_list,
dashboard = dashboard,
application= application,
configuration=configuration,
mqtt_sources=mqtt_sources )
où :
•.block_with_data_list : contient une liste regroupant les informations sur le bloc à afficher (couleur, icône, titre) associée aux données à inclure (valeur télémétrique, disponibilité d’historique) ainsi que le block_config sous la forme d’un objet Python.
•.dashboard : informations sur la définition du tableau de bord affiché.
•.configuration : informations sur la configuration.
•.application : informations sur l’application.
Le code du template dashboard.html est relativement concis.
01: {% extends "base.html" %}
02: {% import "macro/block.macro" as Block %}
03: {% set title = dashboard.label %}
04: {% set title_url = url_for(’dashboard’, id=dashboard.id ) %}
05: {% set dash_color = dashboard.color %}
06: {% block actions %}
07: <li><a href="{{
08: url_for(’block_list’, dash_id = dashboard.id)
09: }}">
10: <i class="material-icons">build</i>
11: </a>
12: </li>
13: <li>
14: <a href="{{
15: url_for(’block_add’, dash_id = dashboard.id)
16: }}">
17: <i class="material-icons">add</i>
18: </a>
19: </li>
20: <li>
21: <a href="{{
22: url_for(’dashboard’, id = dashboard.id)
23: }}">
24: <i class="material-icons">refresh</i>
25: </a>
26: </li>
27: {% endblock %}
28: {% block content %}
29: <div class="row">
30: {% if block_with_data_list | length <= 0 %}
31: <div class="col s12">
32: <div class="amber lighten-3">
33: <div class="card-content black-text">
34: <span class="card-title">
35: <strong>Truc et astuce</strong>
36: </span>
36: <p>Il n’y a pas encore de bloc dans le
37: dashboard.<br />Cliquer sur l’icône
38: ’+’ en haut à droite pour ajouter un
39: premier bloc.<br/>
40: </p>
42: </div>
43: </div>
44: </div>
45: {% endif %}
46:
47: {% for bwd in block_with_data_list %}
48: <div id="an_id_block"
49: class="{{ bwd.block[’color’] }} center blocs col s12 m4 l3 ">
50: {#- bwd = Block With Data -#}
51: {%- if bwd.block_data[’tsname’] -%}
52: <a href="{{
53: url_for( ’topic_history’,
54: dash_id=dashboard.id,
55: block_id=bwd.block[’id’],
56: _from=0, _len=bwd.block[’hist_size’] )
57: }}">
58: <span class="new badge black white-text"
59: data-badge-caption="">
60: <i class="material-icons">zoom_in</i>
61: </span>
62: </a>
63: {%- endif -%}
64: {{ Block.make_block(
65: bwd.block[’block_type’],
66: bwd.block[’id’],
67: bwd.block[’title’],
68: bwd.block_data[’value’],
69: bwd.block_config,
70: {’color’:bwd.block[’color’],
71: ’color_text’:bwd.block[’color_text’],
72: ’icon’:bwd.block[’icon’],
73: ’rectime’:bwd.block_data[’rectime’]
74: }
75: ) | safe }}
76: </div>
77: {% endfor %}
78:
79: </div>
80: {% endblock %}
81: {% block onDocumentReady %}
82: setTimeout( function(){
83: window.location.reload();
84: }, {{ configuration.refresh_time*1000 }} );
85:
86: block_configs = {};
87: {% for bwd in block_with_data_list %}
88: {% if bwd.block[’block_config’] %}
89: block_configs[’{{ bwd.block[’id’] }}’] =
90: JSON.parse( {{ bwd.block[’block_config’] | tojson }} );
91: {% else %}
92: block_configs[’{{ bwd.block[’id’] }}’] = undefined;
93: {% endif %}
94: {% endfor %}
95:
97: mqtt_sources = {{ mqtt_sources | tojson }};
99: {% endblock %}
•.Ligne 1 : utilisation de la balise {% extends %} pour inclure le template de base.
•.Ligne 2 : importation des macros contenues dans macro/block.macro permettant de dessiner les blocs d’informations, et association au namespace Block.
•.Lignes 3 à 5 : définition des variables Jinja utilisées dans le template de base.
•.Lignes 7 à 26 : définition des actions (à droite dans la barre de titre). Ces actions renvoient vers différentes routes de views.py.
•.Ligne 28 : définition du block content (le contenu principal de la page). La définition s’étend jusqu’à la ligne 80 (presque à la fin du template).
•.Ligne 29 : ouverture d’une row Materialize.
•.Lignes 30 à 45 : la balise {% if %} permet de vérifier si le tableau de bord contient au moins un bloc à afficher (si block_with_data_list n’est pas vide). S’il n’y a rien à afficher, alors un message invite l’utilisateur à ajouter un premier bloc dans le tableau de bord.
•.Lignes 47 et 48 : pour chacun des blocs bwd contenus dans block_with_data_list, il s’agit donc d’instances de BlockWithData, la boucle {% for %} procède à l’affichage du bloc en utilisant un élément <div> de classe CSS blocs. À noter que la couleur du bloc est obtenue grâce à une classe CSS de couleur (red, blue, etc.) dont la valeur correspond à l’évaluation de l’expression {{ bwd.block[’color’] }}. La boucle {% for %} et l’élément <div> s’étendent jusqu’à la ligne 76. Entre les lignes 48 et 76, le template traite exclusivement l’affichage du contenu d’un bloc bwd.
•.Lignes 51 à 63 : si la donnée télémétrique (block_data) mentionne la présence d’une table d’historique, alors afficher la petite icône en forme de loupe en haut à droite du bloc.
•.Lignes 64 à 75 : appel de la macro make_block (voir ci-dessous) qui prend en charge le rendu d’un contenu du bloc entre les éléments <div> et </div> du bloc. La macro make_block est définie comme suit : {% macro make_block( block_type, id, title, label, config, params ) %} reprenant respectivement le type de bloc, son identifiant unique, le titre du bloc, le libellé à afficher à l’intérieur (la donnée télémétrique), le block_config associé au bloc et un dictionnaire de paramètres nommés utiles à l’affichage comme le nom de l’icône, les couleurs de texte et de fond, les date et heure de l’information ou toute autre information utile). Cela sera abordé plus en détail à la section suivante.
•.Ligne 74 : la macro make_block génère du contenu HTML, l’appel est donc marqué comme « sûr » à l’aide du filtre safe. Cela évite que tous les caractères <, >, &, ’, " générés par la macro soient automatiquement transformés en entité HTML (<, >, &, ', " ) !
•.Lignes 76 et 77: fin de la boucle et de l’élément <div> d’affichage d’un bloc.
•.Ligne 79 : fin de la row Materialize.
•.Ligne 80 : définition du block onDocumentReady qui permet d’ajouter du code JavaScript dans le template de base.
•.Lignes 82 à 84 : mise en place d’un timer JavaScript pour recharger automatiquement la page au bout du délai mentionné dans le fichier de configuration. À noter que la fonction JavaScript setTimeout() reçoit des millisecondes en paramètres, ce qui explique la présence du multiplicateur 1000.
•.Ligne 86 : définition d’un dictionnaire JavaScript nommé block_configs. La mention du pluriel avec « s » indique que la variable JavaScript embarque plusieurs block_config. Ainsi, le dictionnaire block_configs contient une entrée vers un block_config pour chacun des blocs (block_id) inclus dans le tableau de bord. Cela permet au code JavaScript d’avoir accès aux paramètres block_config avec _block_config = block_configs[ id_du_bloc ];
•.Ligne 87 : utilise une balise {% for %} pour parcourir chaque bloc inclus dans le tableau de bord.
•.Ligne 88 : s’il y a un contenu défini dans le champ ’block_config’, alors l’injecter dans le dictionnaire block_configs sinon passer en ligne 92.
•.Lignes 89 et 90 : injecter la définition d’un block_config dans le dictionnaire JavaScript block_configs. Notez que bwd.block[’block_config’] contient une structure JSON sous forme de texte. Ainsi, l’évaluation par Jinja de {{ bwd.block[’block_config’] | tojson }}retourne une chaîne de caractères contenant la structure JSON définie par l’utilisateur pour le bloc. Ce qui correspond, par exemple, au contenu HTML suivant block_configs[’11’] = JSON.parse( "{ \"switch\": \r\n { \"check\":\"MARCHE\" ,\r\n \"uncheck\" ..... }\r\n}" ); Lorsque JavaScript exécutera JSON.Parser(), la structure JSON sera recréée et injectée dans le dictionnaire JavaScript block_configs.
•.Ligne 92 : indique que le block_config est vide en injectant la valeur undefined pour le block_id. Note : en JavaScript, undefined est équivalent au None de Python.
•.Ligne 97 : l’évaluation par Jinja de {{ mqtt_sources | tojson }} permet de transformer la structure Python mqtt_sources en objet JSON, le tout directement assigné à la variable JavaScript mqtt_sources. Cela produit le contenu HTML suivant directement interprétable par JavaScript. mqtt_sources = {"mqtt_pythonic": {"passwd": "21052017", "port": 1883, "server": "pythonic.local", "username": "pusr103"}};
Pour rappel, la structure Python mqtt_sources est un dictionnaire produit à partir du fichier de configuration à l’aide de la fonction get_mqtt_sources( as_dict=True ) de models.py.
Le projet Dashboard prévoit deux séries de macros Jinja :
•.templates/macro/block.macro : uniquement destinée au rendu des blocs dans un tableau de bord.
•.templates/macro/M_input.macro : uniquement destinée au rendu de zones de saisie Materialize.
La macro make_block est utilisée pour réaliser le rendu de l’information télémétrique dans un bloc du dashboard entre les éléments <div> et </div>.
Cette macro est le point d’entrée principal pour tous les blocs affichés et délègue le rendu final du bloc à d’autres macros en fonction du type de bloc à afficher (icon, big_text, switch).
Signature de la macro
{% macro make_block( block_type, id, title, label, block_config, params ) %}
Avec les paramètres :
•.bloc_type : définit le type de bloc parmi les éléments supportés (icon, big_text, switch).
•.id: : identifiant unique du bloc. Cette information peut être utilisée pour nommer les éléments générés du DOM. Cet id correspond à l’id du bloc dans la base de données dashboard (voir table dash_blocks).
•.title : titre du bloc affiché en première ligne.
•.label : libellé du bloc. Cet élément correspond à l’information télémétrique à afficher.
•.block_config : paramètres encodés manuellement lors de la définition du bloc. Transformé du texte au format JSON vers structure Python.
•.params : un dictionnaire de paramètres. Il permet d’ajouter des paramètres sans modifier la signature de la macro make_block. Les paramètres communiqués sont les suivants : color, color_text, icon, rectime correspondant respectivement à la couleur du bloc, la couleur du texte, le nom de l’icône Materialize et l’heure de capture de la donnée télémétrique.
Appel de la macro
L’appel de la macro depuis le template dashboard.html, repris ci-dessous, met en évidence l’extraction des différents paramètres passés à la macro :
64: {{ Block.make_block(
65: bwd.block[’block_type’],
66: bwd.block[’id’],
67: bwd.block[’title’],
68: bwd.block_data[’value’],
69: bwd.block_config,
70: {’color’:bwd.block[’color’],
71: ’color_text’:bwd.block[’color_text’],
72: ’icon’:bwd.block[’icon’],
73: ’rectime’:bwd.block_data[’rectime’]
74: }
75: ) | safe }}
Les informations extraites avec bwd.block[] font références à la configuration du bloc (donc la table dash_blocks dans la base de données dashboard.db).
Les informations extraites avec bwd.block_data[] font référence aux données télémétriques collectées (donc la table topicmsg dans la base de données pythonic.db).
Les paramètres de configuration du bloc (au format JSON, saisis par l’utilisateur) sont disponibles sous forme d’objet Python.
Les lignes 70 et 74 : création d’un dictionnaire de paramètres avec les paramètres de rendu de bloc.
Définition de make_block()
La macro make_block() ne fait rien de particulièrement exotique. Elle se contente de vérifier que le paramètre block_type contient une valeur connue, puis délègue l’exécution aux macros block_icon(), block_big_text(), etc.
Lorsqu’un nouveau type de bloc est ajouté, un nouveau branchement vers une macro block_nouveau_bloc_type() est ajouté dans la macro make_block().
01: {% set block_types = [’icon’, ’big_text’] %}
02:
03: {% macro make_block( block_type, id, title, label,
04: config, params ) %}
05: {% if not(block_type in block_types) %}
06: {{ block_error( "Error",
07: "’%s’ block_type is not registered in the
08: block_types Jinja variable" % block_type,
09: None )
10: }}
11: {% elif block_type == ’icon’ %}
12: {{ block_icon( id, title, label, config,
params ) }}
13: {% elif block_type == ’big_text’ %}
14: {{ block_big_text( id, title, label, config,
params ) }}
15: {% else %}
16: {{ block_error( id, "Error",
17: "make_block Jinja macro does not know how
18: to handle block_type="+block_type,
19: config, None ) }}
20: {% endif %}
21: {% endmacro %}
•.Ligne 1 : définition des types de blocs existants.
•.Lignes 5 à 10 : vérifie si le type de bloc mentionné correspond bien à un élément existant. Si ce n’est pas le cas, le template affiche un bloc d’erreur à la place du contenu.
•.Lignes 11 et 12 : si le type de bloc est icon, alors le rendu du bloc est délégué à la macro block_icon() à laquelle tous les paramètres de make_block() sont transmis.
•.Lignes 13 et 14 : utilisation de la macro block_big_text() si le type de bloc est big_text.
•.Lignes 15 à 20 : affichage d’un bloc d’erreur si le rendu n’a pas été pris en charge par une macro. Ce point peut sembler redondant avec le test en lignes 6 et 7, mais se montrera utile en cas d’oubli lors du développement d’un nouveau bloc.
Cette macro effectue le rendu d’un bloc contenant une icône et affiche le libellé (donnée télémétrique) sous l’icône.
Bloc ICON tel que défini en début de chapitre
Le bloc est généré à l’aide de la macro block_icon().
01: {% macro block_icon( id, title, label, config, params ) %}
02: <h4 class="{{ params[’color_text’] }}-text">
03: {{ title }}
04: </h4>
05: <i class="material-icons {{ params[’color_text’] }}-text"
06: style="font-size:6rem;">
07: {{ params[’icon’] }}
08: </i>
09: <br>
10: <h5 class="{{ params[’color_text’] }}-text">
11: {{ label }}
12: </h5>
13: {% if params[’rectime’] %}
14: <p id="{{ id }}_rectime_footer"
15: class="foot {{ params[’color_text’] }}-text">
16: {{ params[’rectime’] | strftime("elapse") }}
17: </p>
18: {% endif %}
19: {% endmacro %}
•.Lignes 2 à 4 : affichage du titre correspondant à la zone 1.
•.Lignes 5 à 8 : affichage de l’icône Materialize dans la zone 2. La couleur de l’icône est altérée en utilisant une classe de couleur de texte comme blue-text, red-text, etc. à partir du dictionnaire params ( params[’color_text’] auquel est concaténé ’-text’).
•.Lignes 10 à 12 : affichage du libellé ou de la donnée télémétrique dans la zone 3.
•.Lignes 13 à 18 : si elle est disponible dans le dictionnaire params, afficher l’heure d’enregistrement de la donnée télémétrique (params[’rectime’]) dans la zone 4. Le filtre Jinja strftime() permet d’afficher le temps depuis la date d’enregistrement (cf. Détails de l’application Flask - Les filtres Jinja personnalisés dans ce chapitre). Comme pour l’icône, la couleur du texte de pied de bloc est modifiée à l’aide d’une classe CSS de type texte.
Cette macro fait le rendu d’un bloc contenant uniquement du texte, la donnée télémétrique au centre du bloc.
Bloc BIG_TEXT tel que défini dans le chapitre
Le bloc est généré à l’aide de la macro block_big_text().
01: {% macro block_big_text( id, title, label,
02: config, params ) %}
03: <h4 class="{{ params[’color_text’] }}-text">
04: {{ title }}
05: <h4>
06: <h2 class="{{ params[’color_text’] }}-text">
07: {{ label }}
08: </h2>
09: <p id="{{ id }}_footer"
10: class="foot class="{{ params[’color_text’] }}-text">
11: {{ params[’footer_text’] }}
12: </p>
13: {% if params[’rectime’] %}
14: <p id="{{ id }}_rectime_footer"
15: class="foot {{ params[’color_text’] }}-text">
16: {{ params[’rectime’] | strftime("elapse") }}
17: </p>
18: {% endif %}
19: {% endmacro %}
•.Lignes 3 à 5 : affichage du titre de la zone 1. Utilisation d’une classe CSS pour modifier la couleur du texte.
•.Lignes 6 à 8 : affichage du libellé ou de la donnée télémétrique en grand dans la zone 2.
•.Lignes 9 à 12 : affichage du footer_text s’il est présent dans le dictionnaire params.
•.Lignes 13 à 18 : affichage de l’heure d’enregistrement de la donnée télémétrique, voir les détails dans la macro block_icon().
La page d’édition d’un bloc utilise des macros (provenant de /templates/macro/M_input.macro) pour afficher les listes de sélection <select> Materialize pour la couleur, le type de bloc et l’icône.
Page d’édition d’un bloc
Les différents <select> Materialize sont pris en charge par les macros Jinja :
•.select_color( name, current=None )
•.select_icon( name, current=None )
•.select_block_type( name, current=None )
Où name est le nom du champ <select> Materialize dans le formulaire et current la valeur actuelle du champ <select>.
Par exemple, la macro select_color() permet de générer le <select> Materialize pour la sélection de couleurs, comme présenté ci-dessous.
Sélection de couleur
La macro select_color() est codée comme suit :
01: {% macro select_color( name, current=None ) %}
02: {% set colors=[(’rouge’,’red’),(’rose’,’pink’),
03: (’pourpre’,’purple’),(’pourpre foncé’,
04: ’deep-purple’), (’indigo’,’indigo’),
05: (’bleu’, ’blue’), (’bleu léger’, ’light-blue’),
06: ...
07: (’gris’, ’grey’), (’gris bleu’, ’blue-grey’),
08: (’noir’,’black’), (’blanc’,’white’)] %}
09: <select id="{{ name }}" name="{{ name }}" class="icons">
10: <option value="" disabled
11: {% if current == None %}selected{% endif %}>
12: Choisir une couleur
13: </option>
14: {% for color in colors %}
15: <option value="{{ color[1] }}"
16: data-icon="{{ url_for( ’static’,
17: filename=’images/colors/’+color[1]+’.png’) }}"
18: {% if current == color[1] %}selected{% endif %}
19: > {{ color[0] }} </option>
20: {% endfor %}
21: </select>
22: {% endmacro %}
•.Lignes 2 à 8 : définition d’une liste de tuples (ex. : (’pourpre’,’purple’)) reprenant le libellé de la couleur et le code CSS Normalize correspondant.
•.Ligne 9 : création d’un élément <select> en utilisant le paramètre name pour initialiser les attributs name et id.
•.Lignes 10 à 13 : ajout d’une option « Choisir une couleur ». Cette option est automatiquement sélectionnée s’il n’y a pas de valeur courante mentionnée lors de l’appel de la macro (donc lorsque current = None).
•.Lignes 14 à 20 : énumération des tuples de couleur de la liste colors. Avec la syntaxe {% for color in colors %}, color est un tuple qui vaut tour à tour (’rouge’,’red’), puis (’rose’,’pink’) et ainsi de suite. En conséquence, color[0] retourne le libellé tandis que color[1] retourne le code CSS Materialize correspondant.
•.Lignes 15 à 17 : pour chacune des couleurs de la liste (variable Jinja color), créer un champ <option> dont l’attribut value reprend le code CSS (soit red, pink, etc.) et data-icon le nom du fichier image à afficher en guise d’icône. À noter que le fichier image provient de la ressource /static/images/colors/ en utilisant le code CSS déterminant le nom de ressource, déterminant le nom de fichier (donc /static/images/colors/red.png, /static/images/colors/pink.png).
•.Ligne 18 : insertion du mot-clé selected dans le champ <option> si la valeur correspond au paramètre current de la macro. Cela permet de sélectionner la couleur correcte en cas d’édition de données existantes.
•.Lignes 21 et 22 : fermeture du champ <select> et fin de la macro.
Utilisation de la macro
La macro select_color présentée ci-dessus est utilisée dans la page d’édition d’un bloc de tableau de bord. Cette page est prise en charge par le template bloc_edit.html dont une partie du code est repris ci-dessous :
01: {% extends "base.html" %}
02: {% import "macro/M_input.macro" as M_input %}
03: {% import "macro/block.macro" as Block %}
04: {% if row[’id’] | default("", true) == "" %}
05: {% set title = "Nouveau Bloc" %}
06: {% else %}
07: {% set title = "Modifier Bloc" %}
08: {% endif %}
09: {% set dash_color = dashboard.color %}
10: {% block actions %}
11: {% endblock %}
12: {% block content %}
13: <div class="row">
14: <div class="row">
15: <form action="
16: {{ url_for(’block_add’, dash_id = row[’dash_id’]) }}"
17: method="post" class="col s12">
18: ...
19: <div class="row">
20: <div class="input-field col s12">
21: {{ M_input.select_block_type( "block_type",
22: current=row[’block_type’] ) | safe }}
23: <label>Type de bloc</label>
24: </div>
25: </div>
26: <div class="row">
27: <div class="input-field col s12">
28: {{ M_input.select_color( "color",
29: current=row[’color’] ) | safe }}
30: <label>Couleur du fond</label>
31: </div>
32: </div>
33: <div class="row">
34: <div class="input-field col s12">
35: {{ M_input.select_color( "colortext",
36: current=row[’color_text’] ) | safe }}
37: <label>Couleur du texte</label>
38: </div>
39: </div>
40: <div class="row">
41: <div class="input-field col s12">
42: {{ M_input.select_icon( "icon",
43: current=row[’icon’] ) | safe }}
44: <label>Icon</label>
45: </div>
46: </div>
47: {# Submit button #}
48: <div class="row">
49: <button class="btn waves-effect waves-light red right"
50: type="submit" name="action"
51: value="cancel">Abandonner
52: <i class="material-icons right">close</i>
53: </button>
54: <button
55: class="btn waves-effect waves-light green right"
56: type="submit" name="action"
57: onclick="return checkSubmit();"
58: value="submit">Sauver
59: <i class="material-icons right">check</i>
60: </button>
61: </div>
62:
63: </form>
64: </div>
65: </div>
66: {% endblock %}
67: {% block javascript %}
68: ...
69: function checkSubmit(){
70: var bError = false;
71: if ( $("#title").val().trim().length == 0 ){
72: M.toast( { html: ’Le bloc doit avoir un titre’,
73: classes : ’red’ } );
74: bError = true;
75: }
76: if ( !( $("#block_type").val() ) ){
77: M.toast( { html: ’Le bloc doit avoir un type’,
78: classes : ’red’ } );
79: bError = true;
80: }
81: if ( !( $("#source").val() ) ){
82: M.toast( {
83: html: ’Le bloc doit avoir une source de donnée’,
84: classes : ’red’ } );
85: bError = true;
86: }
87: if ( !( $("#topic").val() ) ){
88: M.toast( { html: ’Le bloc doit avoir un topic’,
89: classes : ’red’ } );
90: bError = true;
91: }
92:
93: return !(bError);
94: }
95: {% endblock %}
La description ci-dessous reprend uniquement les éléments pertinents.
•.La mise à jour des informations dans le bloc switch doit attendre la capture de l’information par push-to-db et le rafraîchissement de la page.Ligne 2 : import des macros Jinja M_input dans l’espace de noms M_input.
Mise à jour de l’information du bloc après recapture de l’état par push-to-db
•.Lignes 12 à 66 : définition du contenu principal de la page avec la balise {% content %}.
•.Ligne 15 : définition du formulaire.
•.Lignes 19 à 25 : ajout du champ <select> Materialize pour le type de bloc.
•.Lignes 26 à 32 : ajout du champ <select> Materialize pour la couleur de fond.
•.Lignes 28 à 29 : appel à la macro select_color() en précisant le nom color comme nom de champ ainsi que la valeur courante (en provenance directe de la table dashes). Étant donné que la macro produit du contenu HTML, le résultat est marqué comme « sûr » avec le filtre safe afin que celui-ci ne soit plus transformé ! Cet appel produit le contenu HTML ci-dessous pour le <select name="color"> :
<select id="color" name="color" class="icons">
<option value="" disabled >
Choisir une couleur
</option>
<option value="red"
data-icon="/static/images/colors/red.png" >
rouge
</option>
<option value="pink"
data-icon="/static/images/colors/pink.png" selected >
rose
</option>
<option value="purple"
data-icon="/static/images/colors/purple.png" >
>pourpre</option>
<option value="deep-purple"
data-icon="/static/images/colors/deep-purple.png"
>pourpre foncé</option>
<option value="indigo"
data-icon="/static/images/colors/indigo.png"
>indigo</option>
</select>
•.Lignes 33 à 39 : appel à la macro select_color() pour créer un second champ de saisie de couleur nommé colortext.
•.Lignes 40 à 46 : appel à la macro select_icon() pour créer un champ de saisie d’icône nommé icon.
•.Lignes 49 à 53 : ajout d’un bouton Abandonner (réalise un POST du formulaire avec le champ action = "cancel").
•.Lignes 54 à 60 : ajout d’un bouton Sauver (réalise un POST du formulaire avec le champ action = "submit"). Ce bouton inclut un attribut onclick comme ceci : onclick="return checkSubmit();". Si la fonction checkSubmit() retourne une valeur true, alors le formulaire sera envoyé, sinon la soumission est annulée.
•.Lignes 67 à 85 : définition de la balise {% block javascript %} pour ajouter du code JavaScript personnalisé.
•.Ligne 69 : définition de la fonction JavaScript checkSubmit() appelée par le bouton Sauver.
•.Ligne 70 : déclaration d’une variable drapeau bError mis à true en cas d’erreur. Par défaut, ce drapeau est à false.
•.Ligne 71 : récupération de la valeur du champ title de la page et vérifier si celui-ci est vide, auquel cas l’exécution passe aux lignes 72 à 74.
•.Ligne 72 : création d’un message Toast pour avertir l’utilisateur.
•.Ligne 74 : activation d’un drapeau bError indiquant qu’il y a eu une erreur.
•.Lignes 76 à 91 : autres tests sur le contenu des champs block_type, source, topic et envoi des messages Toast correspondants.
•.Ligne 93 : la fonction checkSubmit() doit retourner false s’il y a une erreur afin d’empêcher l’envoi du formulaire vers le serveur Flask. La valeur retournée est donc not( bError ) ou !( bError ) en JavaScript.
Cette partie du chapitre décrit toutes les étapes nécessaires à l’ajout d’un nouveau bloc. Le bloc SWITCH.
Le bloc SWITCH
Le bloc SWITCH dispose d’une nouvelle fonctionnalité importante par rapport aux autres blocs : il est destiné à activer un élément distant comme le relais branché sur l’objet chaufferie (cf. Les objets ESP8266 - Objet 4 : Chaufferie).
Objet chaufferie
Pour rappel, l’objet chaufferie :
•.renvoie son état MARCHE/ARRET sur le topic maison/cave/chaufferie/etat, topic qui est capturé par le script Python push-to-db,
•.reçoit ses commandes (MARCHE/ARRET) sur le topic maison/cave/chaufferie/cmd.
Dashboard - fonctionnalités disponibles et manquantes
Le bloc SWITCH doit donc afficher l’état du bouton en fonction des états MARCHE/ARRET présents sur le topic maison/cave/chaufferie/etat, ce qui reste dans les possibilités de l’implémentation actuelle.
Par contre, le bloc SWITCH doit également être capable de réaliser une publication MQTT vers le topic maison/cave/chaufferie/cmd pour modifier l’état de l’objet. Idéalement, le bloc devrait faire directement une souscription sur le topic maison/cave/chaufferie/etat pour réaliser une mise à jour du bloc.
Le schéma de fonctionnement général du projet est donc modifié pour ajouter la fonctionnalité de publication/souscription MQTT directement depuis le navigateur Internet.
Le bloc SWITCH et la publication MQTT en JavaScript
Dashboard - développements à prévoir
Hormis les modifications nécessaires pour l’ajout d’un nouveau bloc, il est évident que le projet Dashboard doit disposer d’un paramétrage complémentaire (pour les paramètres MQTT du bloc et les sources MQTT), ainsi que l’exploitation de MQTT depuis une page HTML (grâce à Paho JavaScript : https://www.eclipse.org/paho/clients/js/)
Quelques développements complémentaires sont nécessaires dans le projet Dashboard pour supporter des paramètres complémentaires pour les blocs et la mise à disposition de sources MQTT depuis le fichier de configuration (comme les sources issues de la base de données, mais avec des serveurs MQTT au lieu de bases de données).
Fichier de configuration
Tout comme pour les sources issues de bases de données, le fichier de configuration (app/dashboard.cfg.default) contient des sources MQTT.
# Sources - the MQTT sources
# allows block to interact with mqtt servers
mqtt_sources = [ ’mqtt_pythonic’ ]
mqtt_pythonic = ’pythonic.local’ # Mqtt server
mqtt_pythonic_port = 1883
mqtt_pythonic_username = ’pusr103’
mqtt_pythonic_passwd = ’21052017’
La variable mqtt_sources contient une liste des sources MQTT disponibles. Dans le cas présent, il s’agit d’une liste avec une unique entrée « MQTT source » valant ’mqtt_pythonic’.
Pour chaque entrée dans la liste, ’mqtt_pythonic’ par exemple, il doit y avoir quatre variables :
•.Une variable mqtt_pythonic qui contient l’adresse du broker MQTT.
•.Une variable mqtt_pythonic_port qui indique le port à utiliser sur le broker MQTT.
•.Une variable mqtt_pythonic_username contenant le login sur le broker (ou None).
•.Une variable mqtt_pythonic_passwd contenant le mot de passe associé au login (ou None).
models.py
Le fichier models.py contient une nouvelle fonction get_mqtt_sources() pour les serveurs MQTT (similaire à get_data_sources() pour les bases de données).
01: def get_mqtt_sources( as_dict = False ):
02:
03: def extract_config( key, default=’_’ ):
04: """ Extrait une valeur nommée depuis la configuration """
05: try:
06: _result = getattr( configuration, key )
07: except:
08: if default==’_’:
09: raise GetDbError(
10: ’Missing %s entry in configuration file!’ % key )
11: else:
12: _result = default
13: return _result
14:
15: mqtt_sources = getattr( configuration, ’mqtt_sources’ )
16: if not( mqtt_sources ):
17: mqtt_sources = []
18: assert type( mqtt_sources ) == list, \
19: "the config file mqtt_sources must be a list of string"
20:
21: if not( as_dict ):
22: return mqtt_sources
23: else:
24: _dic = {}
25: for _source in mqtt_sources:
26: _params = {}
27: _params[’server’] = \
28: extract_config( _source )
29: _params[’port’] = \
30: extract_config( ’%s_port’ % _source )
31: _params[’username’]= \
32: extract_config( ’%s_username’ % _source, None )
33: _params[’passwd’] = \
34: extract_config( ’%s_passwd’% _source, None )
35: _dic[_source] = _params
36: return _dic
Telle qu’elle est définie, la fonction get_mqtt_sources() appelée sans paramètre retourne une simple liste des sources MQTT, soit [ ’mqtt_pythonic’ ].
La syntaxe alternative get_mqtt_sources( as_dict = True ) retourne toute l’information sous forme de dictionnaire.
Dans le cas de la définition du fichier de configuration app/dashboard.cfg.default ci-dessus, l’appel de get_mqtt_sources( as_dict=True ) retourne le dictionnaire Python suivant :
{’mqtt_pythonic’: {’passwd’: ’21052017’,
’port’: 1833,
’server’: ’pythonic.local’,
’username’: ’pusr103’}}
Exploiter la structure avec JSON
Transformer des structures Python en format JSON permet d’exploiter ces mêmes informations dans le navigateur Internet à l’aide de JavaScript.
La structure du dictionnaire peut être transformée en structure JSON à l’aide de l’instruction suivante :
import json
from model import get_mqtt_source
_json = json.dumps( get_mqtt_sources( as_dict = True ) )
Ce qui produit une chaîne de caractères JSON pouvant être incluse dans du code JavaScript.
’{"mqtt_pythonic": {"username": "pusr103", "passwd": "21052017",
"port": 1833, "server": "pythonic.local"}}’
En effet, comme le bloc SWITCH doit contacter directement le broker MQTT, la page doit embarquer l’information pour permettre la connexion sur les sources MQTT. Cela permettra au code JavaScript de contacter le broker MQTT depuis le navigateur internet.
Exploiter la structure avec Jinja
Le moteur de template Jinja propose également le filtre json permettant de transformer un objet Python en objet JSON directement exploitable en JavaScript.
Le template Jinja suivant :
{% block onDocumentReady %}
{#- Injecter définition des sources MQTT #}
mqtt_sources = {{ mqtt_sources | tojson }};
setTimeout(function(){
window.location.reload();
}, {{ configuration.refresh_time*1000 }} );
{% endblock %}
Produit le contenu HTML correspondant dans la page HTML.
mqtt_sources = {"mqtt_pythonic": {"passwd": "21052017", "port": 1883,
"server": "pythonic.local", "username": "pusr103"}};
setTimeout(function(){
window.location.reload();
}, 120000 );
});
Saisie de paramètres
Le bloc SWITCH nécessitera une série de paramètres additionnels. Le champ block_config activé dans la page de configuration du bloc permet de saisir ces informations.
Le template bloc_edit.html a été modifié pour permettre l’édition du champ block_config (anciennement un champ <input type=’hidden’> maintenant transformé en <textarea>).
Activation de la zone d’encodage block_config
La fonction JavaScript checkSubmit() évoquée précédemment est modifiée pour vérifier le contenu du champ block_config.
01: function checkSubmit(){
02: var bError = false;
03: if ( $("#title").val().trim().length == 0 ){
04: M.toast( { html: ’Le bloc doit avoir un titre’, classes : ’red’ } );
05: bError = true;
06: }
07: ...
08:
09: // check JSON
10: var jsontext = $(’#block_config’)[0].value;
11: if( jsontext.length>0 )
12: try {
13: var tojson = JSON.parse( jsontext );
14: }
15: catch( err ){
16: console.error( ’error parsing JSON configuration param! ’+err );
17: M.toast( { html: ’Configuration (JSON) incorrecte’, classes : ’red’ } );
18: M.toast( { html: err, classes : ’red’ } );
19: bError = true;
20: }
21: console.log( tojson );
22:
23: return !(bError);
24: }
•.Ligne 10 : récupération de la valeur du champ block_config.
•.Ligne 11 : si block_config contient du texte, alors essayer d’en convertir le contenu en JSON de façon à pouvoir confirmer la validité de la structure JSON (lignes 12 à 20).
•.Ligne 13 : tentative de parsing du texte provenant de block_config. L’opération est exécutée dans une structure try-catch. Si block_config ne contient pas de contenu JSON valide, alors l’exécution est transférée aux lignes 15 à 20 (la section catch). Si le contenu JSON est valide, alors la fonction checkSubmit() n’arrête pas le processus de soumission.
•.Ligne 16 : envoi du message d’erreur dans la console JavaScript.
•.Ligne 17 : affichage d’un Toast pour informer l’utilisateur de l’erreur.
•.Ligne 19 : placer le drapeau bError à true pour empêcher l’envoi du formulaire.
Inclusion des « block_config » dans dashboard.html
Étant donné que les définitions block_config des différents blocs peuvent embarquer des informations très utiles pour le code JavaScript de la page, ces informations sont injectées dans le template dashboard.html au niveau du bloc Jinja {% block onDocumentReady %} :
{% block onDocumentReady %}
block_configs = {};
{#- Données JSON Block_config (dispo pour le JavaScript) #}
{% for bwd in block_with_data_list %}
{% if bwd.block[’block_config’] %}
block_configs[’{{ bwd.block[’id’] }}’] =
JSON.parse( {{ bwd.block[’block_config’] | tojson }} );
{% else %}
block_configs[’{{ bwd.block[’id’] }}’] = undefined;
{% endif %}
{% endfor %}
{#- Injecter les sources MQTT #}
mqtt_sources = {{ mqtt_sources | tojson }};
setTimeout(function(){
window.location.reload();
}, {{ configuration.refresh_time*1000 }}
);
{% endblock %}
Le code JavaScript crée une variable globale block_configs contenant un dictionnaire vide avec block_configs = {};
Par la suite, la boucle {% for bwd in block_with_data_list %} permet de parcourir tous les blocs et d’initialiser les différentes entrées du dictionnaire.
Si un bloc ne contient pas d’information block_config (pas de structure JSON), alors l’entrée dans le dictionnaire est composée comme ceci :
block_configs[ block_id ] = undefined ;
Si le bloc contient des données JSON dans block_config, alors l’entrée dans le dictionnaire JavaScript ressemble à ceci :
block_configs[ block_id ] =JSON.parse( "{ \"switch\": \r\n {
\"check\":\"MARCHE\" ,\r\n \"uncheck\":\"ARRET\"\r\n },\r\n
\"action\":\r\n { \"checked\": { \"source\": \"mqtt_pythonic\",
\"topic\": \"maison/cave/chaufferie/cmd\", \"message\":\"MARCHE\" },
\r\n \"unchecked\": {\"source\": \"mqtt_pythonic\", \"topic\":
\"maison/cave/chaufferie/cmd\", \"message\": \"ARRET\" }\r\n },
\r\n \"watch\" : \r\n { \"source\": \"mqtt_pythonic\", \"topic\":
\"maison/cave/chaufferie/etat\" }\r\n}" );
Étant donné que les informations JSON sont encodées dans un champ texte réparti sur plusieurs lignes, il est difficile d’inclure le texte tel quel dans le code JavaScript. L’astuce consiste à injecter la structure JSON comme une chaîne de caractères JSON (à l’aide de la balise Jinja {{ bwd.block[’block_config’] | tojson }} ), puis de parser ladite chaîne de caractères (avec JSON.parse() ) dans le JavaScript pour recréer la structure JSON.
Inclure les « block_config » dans la structure BlockWithData
Déjà évoquée dans la section Détails de l’application Flask - Affichage du tableau de bord, la classe BlockWithData permet d’embarquer la définition de block_config sous forme d’une structure d’objet Python.
De la sorte, l’information est disponible lors du rendu du template dashboard.html et durant les appels des macros make_block() :
class BlockWithData( object ):
block = None
block_data = None
block_config = None
def __init__( self, block, block_data, block_config ):
self.block = block
self.block_data = block_data
self.block_config = block_config
def __repr__( self ):
return ’<block: %r, block_data: %r>’ % (self.block, self.block_data)
Avant toute chose, il est important de déterminer les paramètres attendus dans block_config pour le switch. Les informations sont saisies au format JSON et permettent de paramétrer les blocs sans devoir altérer l’interface d’édition.
{
"switch": {
"check":"MARCHE" ,
"uncheck":"ARRET"
},
"action": {
"checked":{
"source": "mqtt_pythonic",
"topic": "maison/cave/chaufferie/cmd",
"message":"MARCHE"
},
"unchecked":{
"source": "mqtt_pythonic",
"topic": "maison/cave/chaufferie/cmd",
"message": "ARRET"
}
},
"watch": {
"source": "mqtt_pythonic",
"topic": "maison/cave/chaufferie/etat" }
}
Le premier niveau contient trois clés :
•.switch : contient la valeur des messages MQTT pour les états cochés (check) ou décochés (uncheck) de la checkbox qui correspondent aux positions Marche/Arrêt du switch.
•.action : contient les messages MQTT à envoyer (et où) lorsque le switch change d’état. Lorsque le switch est basculé à l’état coché, l’action déclenchée est décrite dans la clé "checked". La clé "unchecked" correspond à l’action effectuée lorsque le switch est décoché.
•.watch : contient le topic à surveiller pour être notifié immédiatement par le broker des changements d’état. Les messages doivent également correspondre aux valeurs définies dans switch.
La mention de "source" fait référence aux sources MQTT définies dans le fichier de configuration du projet Dashboard.
Lorsque le switch est placé à l’arrêt (donc la checkbox décochée), la structure permet d’obtenir aisément le topic et le message à envoyer sur le broker MQTT, et ce quel que soit le langage de programmation employé (JavaScript, Python).
En effet, il faut accéder au block_config comme suit en JavaScript :
message = block_config.uncheck.message ;
topic = block_config.uncheck.topic ;
Ou encore comme ceci en Python (après chargement de la structure JSON) :
message = block_config[’uncheck’][’message’]
topic = block_config[’uncheck’][’topic’]
Ou encore comme ceci dans un template Jinja (lorsque l’objet block_config Python est communiqué au template) :
{% set message = block_config.uncheck.message %}
{% set topic = block_config.uncheck.topic %}
Les éléments sont maintenant en place pour ajouter un nouveau type de bloc nommé switch dans le projet.
Icône du bloc
Le nouveau type de bloc doit avoir une icône, une image PNG de 48 x 48 pixels placée dans le répertoire /app/static/images/block_types/ et nommée avec type du bloc (donc switch.jpg) :
Icône du bloc switch dans les ressources statiques
templates/block.macro
Modifier la définition de block_types pour ajouter le type de bloc switch.
{% set block_types = [’icon’, ’big_text’, ’switch’] %}
Indiquer la dépendance à MQTT en ajoutant switch dans la définition de mqtt_required_blocks. Cela permet d’indiquer que si un des blocs de la liste est présent dans la page, alors il faut inclure les ressources MQTT.
{% set mqtt_required_blocks = [’switch’] %}
Ajouter la macro block_switch pour réaliser le rendu du bloc dans le tableau de bord.
{% macro block_switch( id, title, label, config, params ) %}
<h4>{{ title }}</h4>
<i class="material-icons"
style="font-size:6rem;">{{ params[’icon’] }}</i>
<div class="switch">
<label>
Off
<input type="checkbox"
id="checkbox_switch_{{ id }}"
{% if config.switch and config.switch.check == label %}
checked
{% endif %}>
<span class="lever"></span>
On
</label>
</div>
<p id="{{ id }}_footer" class="foot">
{{ label }} {{ params[’footer_text’] }}
</p>
{% if params[’rectime’] %}
<p id="{{ id }}_rectime_footer" class="foot">
{{ params[’rectime’] | strftime("elapse") }}
</p>
{% endif %}
{% endmacro %}
Le champ <input type="checkbox"> porte un id spécifique commençant par "checkbox_switch_" et incluant ensuite l’id du bloc.
L’instruction {% if config.switch and config.switch.check == label %} permet de comparer la valeur du libellé à afficher (donc le message MQTT) à la configuration du bloc (le block_config encodé pour le bloc switch).
S’il apparaît que le libellé label correspond à la valeur indiquée dans le paramètre check (soit ’MARCHE’), alors le switch est marqué comme coché sur la page (avec l’attribut checked).
Le restant du contenu du bloc ne diffère pas vraiment des autres blocs présentés jusqu’à maintenant.
La publication de l’ordre ’MARCHE’ ou ’ARRET’ n’est pas implémentée directement au sein du bloc switch. Cette opération est déléguée à la fonction on_switch_change() (voir block.js) associée à l’événement onchange() du/des switch(s) par le template de base base.html.
Attacher l’événement onchange des switchs
Le bloc switch utilise un composant checkbox (<input type="checkbox">) pour manipuler l’état du composant. Il est possible de capturer le changement d’état du composant en greffant une fonction de rappel sur l’événement onchange.
Le but de l’événement onchange est d’envoyer une publication sur le broker MQTT lorsque la checkbox change d’état.
Le template de base est modifié de sorte à attacher la fonction JavaScript on_switch_change à l’événement onchange des switchs.
Le code suivant a été inséré dans /templates/base.html :
<script type="text/javascript">
<!--
M.AutoInit();
$(document).ready(function(){
{#- Needed for html select ui -#}
$(’select’).formSelect();
{#- Toasting Flash Messages #}
...
{#- Attach event to switch_block #}
$(’[id^="checkbox_switch_"]’).each( function(){
console.log( ’attach onchange to ’ + $(this)[0].id );
$(this)[0].onchange = on_switch_change
} );
{#- Custom onDocumentReady Javascrit content -#}
{% block onDocumentReady %}{% endblock %}
});
{% block javascript %}{% endblock %}
-->
La requête jQuery $(’[id^="checkbox_switch_"]’) permet de localiser tous les composants ayant un id commençant par "checkbox_switch_".
Ensuite, la méthode each() permet d’associer l’événement onchange pour chacun des éléments trouvés. L’événement onchange est attaché à la fonction on_switch_change() grâce à l’instruction $(this)[0].onchange = on_switch_change. Notez qu’un petit message dans la console JavaScript indique l’association.
La fonction JavaScript on_switch_change() est définie dans le fichier /app/static/js/block.js regroupant le code JavaScript utilisé par et pour les blocs.
Du code JavaScript complémentaire
Le template de base est également modifié pour inclure des ressources complémentaires :
•.Inclusion de /app/static/js/block.js qui prend en charge le code JavaScript destiné aux blocs (y compris MQTT en JavaScript et le bloc switch).
•.Inclusion mqttws31.js, la bibliothèque Paho JavaScript pour utiliser un client MQTT en JavaScript.
Comme précisé en début de section (cf. Bloc Switch (marche/arrêt)), le bloc switch doit être capable d’envoyer des messages MARCHE/ARRET sur le topic maison/cave/chaufferie/cmd en vue de commander l’objet de la chaufferie.
Idéalement, le bloc switch devrait faire une souscription sur le topic maison/cave/chaufferie/etat pour détecter les changements d’état et les reporter immédiatement dans l’affichage du bloc.
Publication de messages MQTT depuis JavaScript
La page générée pour afficher le tableau de bord doit pouvoir réaliser des publications et des souscriptions MQTT depuis le navigateur web. Cela suppose d’utiliser un client MQTT JavaScript.
La bibliothèque JavaScript du projet Eclipse Paho propose un client MQTT pouvant être utilisé dans un navigateur. Pour rappel, le projet Paho offre des implémentations open source de clients MQTT pour de nombreux langages de programmation.
La bibliothèque JavaScript Paho est disponible sur le lien suivant : https://www.eclipse.org/paho/clients/js/
L’exemple suivant, inspiré du projet Paho, peut être utilisé pour tester le client MQTT JavaScript :
// Inclusion du script
// <script type="text/javascript"
// src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" >
// </script>
// Créer un client MQTT
client = new Paho.MQTT.Client( "pythonic.local",
9001,
"Dashboard");
// Définir les fonctions de rappel
client.onConnectionLost = onConnectionLost;
client.onMessageArrived = onMessageArrived;
// Connecter le client
client.connect({onSuccess:onConnect,
userName:"pusr103",
password:"21052017" });
// Appelé quand le client se connecte
function onConnect() {
console.log("onConnect");
// Réaliser une souscription
client.subscribe("maison/cave/chaufferie/etat");
// Envoi d’un message
message = new Paho.MQTT.Message("MARCHE");
message.destinationName = "maison/cave/chaufferie/cmd";
client.send(message);
}
// Appelé lors d’une perte de connexion
function onConnectionLost(responseObject) {
if (responseObject.errorCode !== 0) {
console.log("onConnectionLost:"+responseObject.errorMessage);
}
}
// Appelé lors de la réception d’un message
function onMessageArrived(message) {
console.log("onMessageArrived:"+message.payloadString);
}
La bibliothèque JavaScript Paho du client MQTT s’appuie sur les WebSockets.
Le WebSocket est une technologie web relativement récente autorisant la communication dans les deux sens. Cela permet au serveur d’envoyer des messages et des notifications vers le client (le script) fonctionnant dans le navigateur Internet.
Préalablement à la technologie WebSocket, seul le client pouvait initier l’envoi d’un message vers le serveur. Les applications web de type chat et messagerie devaient donc interroger régulièrement le serveur pour être averties de la disponibilité de messages.
Le graphique ci-dessous présente le cheminement d’une publication MQTT via le client MQTT JavaScript.
Client MQTT JavaScript et WebSocket
Cela implique deux choses :
1. | Que le navigateur supporte la technologie WebSocket (ce qui est le cas des navigateurs récents, voir le lien http://caniuse.com/websockets pour plus de détails). |
2. | Que le broker accepte des connexions WebSocket. Ce qui est prévu dans le cas du broker MQTT Mosquitto. |
Le support WebSocket s’active dans le fichier de configuration.
sudo nano /etc/mosquitto/mosquitto.conf
Dans lequel il faut ajouter les entrées suivantes juste au-dessus de la ligne pid_file /var/run/mosquitto.pid.
port 1883
listener 9001
protocol websockets
Puis, il est nécessaire de redémarrer le broker MQTT et push-to-db avec les commandes :
sudo systemctl restart mosquitto.service
sudo systemctl restart push-to-db.service
Le service push-to-db doit être redémarré, car toutes ses souscriptions sont annulées lors du redémarrage du broker. Cela signifie que s’il n’est pas redémarré après le broker, push-to-db ne capturera plus aucun message !
Pour tester le script d’exemple mentionné ci-dessus, il faut tout d’abord naviguer vers n’importe quelle page du projet Dashboard de façon à charger le fichier JavaScript https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js dans le contexte courant.
Ceci fait, le script peut être testé depuis l’Ardoise JavaScript des outils de développements web du navigateur (cf. Navigateur FireFox) et ouvrir la console web permet de voir les différents messages de log JavaScript.
Tester le client MQTT JavaScript depuis l’ardoise JavaScript
Pour une raison inconnue, la connexion MQTT via WebSocket retourne systématiquement une erreur, même lorsque l’authentification est désactivée sur le broker MQTT (cf. fichier mosquitto.conf).
Les logs de Mosquitto disponibles dans /var/log/mosquitto/mosquitto.log mentionnent des erreurs sur le socket.
1536248457: New connection from 192.168.1.22 on port 1883.
1536248457: Socket error on client <unknown>, disconnecting.
1536248457: New connection from 192.168.1.22 on port 1883.
1536248457: Socket error on client <unknown>, disconnecting.
1536248519: New connection from 192.168.1.22 on port 1883.
1536248519: Socket error on client <unknown>, disconnecting.
1536248519: New connection from 192.168.1.22 on port 1883.
Selon les circonstances, il semblerait qu’une version trop récente de la bibliothèque libwebsockets 2.1.x provoque des problèmes de connexion WebSocket sur le broker MQTT Mosquitto 1.4 (version installée). Voir ce billet https://github.com/eclipse/mosquitto/issues/336.
Il est donc nécessaire d’attendre une mise à jour ou de procéder à une rétrogradation de libwebsockets à la version 2.0.x.
Derrière ce juron emprunté au capitaine Haddock (cf. bande dessinée « Tintin » de Hergé) se cache une très mauvaise surprise découverte lors de l’écriture de cette fin de chapitre.
Le code JavaScript proposé ci-dessus ne peut pas fonctionner en ce mois de septembre 2018 ! Une situation particulière qui n’existait pas il y a peu et qui sera certainement corrigée sous peu.
À vingt jours de la remise de l’ouvrage, il aura fallu accuser le choc, comprendre d’où venait le problème puis trouver rapidement une solution acceptable.
Mauvaise surprise et alternative
Le fait de ne pas pouvoir exploiter le client MQTT JavaScript constitue plus qu’une mauvaise surprise puisque cela va empêcher de contrôler l’objet chaufferie !
Après de nombreuses investigations et le temps pressant, c’est là où les bonnes vieilles recettes et technologies éprouvées peuvent s’avérer très utiles.
Si le client MQTT JavaScript ne peut pas envoyer de message directement au broker MQTT, il reste possible de le faire via une requête AJAX (Asynchronous JavaScript and XML) vers l’application Dashboard pour lui demander de réaliser cette publication vers le broker MQTT.
Utilisation d’une requête AJAX à la place de WebSocket
Si cela permet de relayer facilement des publications vers le broker MQTT, cette solution de remplacement ne permettra pas d’être notifié des changements d’état.
En plaçant une route /MqttProxyPublish dans l’application Flask, le dashboard peut réceptionner une requête de publication via HTTP et utiliser le client MQTT Python pour republier le message vers le broker.
Le fichier views.py met en place une nouvelle route /MqttProxyPublish capable de réceptionner une requête AJAX comportant les trois éléments suivants dans une structure JSON :
•.source : indique la source MQTT dans le fichier de configuration. Indique vers quel serveur MQTT la publication doit être relayée.
•.topic : le topic sur lequel le message doit être publié.
•.msg : le message à publier.
01: @app.route( ’/MqttProxyPublish’, methods=[’POST’] )
02: def mqtt_publish_proxy():
03: data = request.data
04: app.logger.debug( u’MqttProxyPublish for %s’% data )
05: try:
06: dataDict = json.loads(data)
07: except Exception as err:
08: return make_response(
09: (u’Format JSON invalide. %s’%err, 400) )
10:
11: # Doit avoir les éléménts suivants :
12: # data = {"source":"mqtt_pythonic",
13: # "topic":"maison/cave/chaufferie/cmd",
14: # "msg":"ARRET"}
15: try:
16: assert ’source’ in dataDict, "JSON: ’source’ manquante."
17: assert ’topic’ in dataDict, "JSON: ’topic’ manquante."
18: assert ’msg’ in dataDict, "JSON: ’msg’ manquante."
19: except Exception as err:
20: return make_response(
21: (u’Format JSON invalide. %s’%err,400) )
22:
23: _source = dataDict[’source’]
24: _topic = dataDict[’topic’]
25: _msg = dataDict[’msg’]
26: try:
27: mqtt_info = get_mqtt_sources(as_dict=True)[_source]
28: except:
29: return make_response( (u’Source invalide!’, 400) )
30:
31: try:
32: import paho.mqtt.client as mqtt_client
33: client = mqtt_client.Client(
34: client_id="dashboard_MqttProxyPublish" )
35: if mqtt_info[’username’]:
36: client.username_pw_set(
37: username=mqtt_info[’username’],
38: password=mqtt_info[’passwd’])
39:
40: client.connect( host=mqtt_info[’server’],
41: port=mqtt_info[’port’] )
42: client.publish( _topic, _msg ) # QoS 0
43: except Exception as err:
44: app.logger.error( ’MqttProxyPublish impossible
de relayer vers le Broker pour %s’ % data )
45: app.logger.error( err )
46: make_response( ( u’Erreur Broker! %s’%err , 400) )
47:
48: return make_response( (u’%s envoyé!’%_msg , 200 ) )
•.Ligne 1 : déclaration de la route qui accepte uniquement la méthode POST.
•.Ligne 3 : récupération des données envoyées dans la requête.
•.Ligne 6 : chargement de la structure JSON contenue dans les données envoyées.
•.Lignes 8 et 9 : capture de l’erreur si le chargement JSON échoue et renvoi d’une erreur 400 (Bad Request).
•.Lignes 15 à 21 : vérification de la présence des éléments nécessaires dans la structure JSON. Renvoi d’une erreur 400 en cas de problème.
•.Lignes 23 à 35 : extraction des valeurs de _topic, _msg (message) et _source (MQTT source) depuis la structure JSON.
•.Ligne 27 : récupération des informations du broker MQTT depuis le fichier de configuration du projet Dashboard (ex. : voir la fonction get_mqtt_sources() dans models.py). Renvoi d’une erreur 400 si la source MQTT ne peut pas être identifiée.
•.Lignes 31 à 42 : utilisation du client MQTT Python (cf. exemples en Python), création de la connexion et publication du message sur le topic mentionné.
•.Lignes 43 à 46 : capture des exceptions durant la publication MQTT et renvoi d’une erreur 400 avec le détail de l’erreur.
•.Ligne 48 : la publication s’est déroulée sans erreur, renvoi d’un statut 200.
La fonction JavaScript on_switch_change() est définie dans le fichier /app/static/js/block.js.
La fonction on_switch_change() est rattachée à tous les switchs (événement onchange) durant le chargement de la page.
Cette fonction est appelée à chaque fois qu’un switch (checkbox) change d’état.
Voici le détail de la fonction JavaScript on_switch_change.
01: function on_switch_change( event ){
02: var checkbox = event.target
03:
04: console.debug( ’on_switch_change sur id ’+checkbox.id+
05: ’ pour ’+
06: (checkbox.checked ? " CHECKED " : " unchecked ") );
07: // Extraction du block ID (ex : "checkbox_switch_14")
08: var _arr = checkbox.id.split(’_’);
09: var block_id = _arr[ _arr.length-1 ];
10: // Retrouver le block_config correspondant
11: var block_config = block_configs[ block_id ];
12: var err = check_block_config ( ’switch’,
13: block_config );
14: if( err ){
15: M.toast( {
16: html:’Erreur configuration block: ’+err,
17: classes:’red’ } );
18: return
19: }
20:
21: if( checkbox.checked ){
22: var _source = block_config.action.checked.source;
23: var _topic = block_config.action.checked.topic;
24: var _msg = block_config.action.checked.message;
25: }
26: else {
27: var _source = block_config.action.unchecked.source;
28: var _topic = block_config.action.unchecked.topic;
29: var _msg = block_config.action.unchecked.message;
30: }
31:
32: console.log( JSON.stringify(
33: {source:_source,
34: topic:_topic,_msg:_msg} ) );
35:
36: var jqxhr = $.ajax({
37: url: ’/MqttProxyPublish’,
38: type: ’POST’,
39: data: JSON.stringify(
40: {source:_source,
41: topic:_topic,msg:_msg} ),
42: contentType: ’application/json; charset=utf-8’
43: }).done(
44:
45: function(response, status, xhr) {
46: M.toast( {
47: html: response,
48: classes: ’green’ } );
49: }).fail(
50:
51: function(response, status, xhr) {
52: M.toast( {
53: html: response.responseText,
54: classes: ’red’ } );
55: M.toast( {
56: html:’Erreur ajax!’,
57: classes:’red’} );
58: }) .always(
59:
60: function() {
61: console.debug( ’’ );
62: });
63:
64: }
•.Ligne 1 : déclaration de la fonction on_switch_change et son paramètre event communiqué lors de l’appel de la fonction.
•.Ligne 2 : récupération du composant checkbox (case à cocher) qui a provoqué l’envoi de l’événement.
•.Lignes 4 à 6 : trace mentionnant l’événement, l’identifiant du composant impliqué et état coché/non coché. L’identifiant est celui fourni dans l’attribut id durant la constitution de la page HTML incluant le bloc SWITCH (voir fichier block.macro et macro Jinja make_switch() ).
•.Lignes 8 et 9 : utiliser le caractère « _ » pour sectionner l’identifiant du composant. Par exemple, "checkbox_switch_14".split(’_’) produit [ "checkbox", "switch", "14" ]. Ainsi découpé, il est possible de récupérer l’id du bloc (le block_id) stockée en dernière position.
•.Ligne 11 : récupération des informations block_config pour le block_id concerné. Récupération faite depuis le dictionnaire block_configs (injecté dans la page par le template dashboard.html).
•.Lignes 12 et 19 : vérification des informations dans le bloc de configuration. Affichage d’un Toast en cas de problème.
•.Lignes 21 à 29 : récupération des informations nécessaires à la publication (la source, le topic et le message) en fonction de l’état du switch.
•.Lignes 32 à 34 :trace du message JSON tel qu’il sera envoyé sur la route /MqttProxyPublish.
•.Lignes 36 à 42 : création d’une requête AJAX JavaScript en mentionnant l’URL de destination /MqttProxyPublish, le type de requête (POST) et les données à envoyer (structure JSON).
•.Lignes 43 à 48 : fonction appelée lorsque l’appel a réussi (.done()). La fonction affiche un Toast avec le contenu renvoyé par /MqttProxyPublish. Le message étant « ARRET envoyé » ou « MARCHE envoyé » avec un statut 200.
•.Lignes 49 à 58 : fonction appelée lorsque l’appel échoue (.fail()). La fonction affiche un Toast avec le contenu du message d’erreur renvoyé par /MqttProxyPublish (et le statut 400).
Le plus simple pour vérifier le fonctionnement de l’ensemble est d’utiliser l’utilitaire mosquitto_sub pour vérifier les messages circulants sur les sous-topics de maison/cave/chaufferie.
mosquitto_sub -h pythonic.local -t "maison/cave/chaufferie/#" -v -u "pusr103"
-P "21052017"
ce qui produit le résultat suivant lorsque le switch est activé et déactivé à tour de rôle.
$ mosquitto_sub -h pythonic.local -t "maison/cave/chaufferie/#" -v -u
"pusr103" -P "21052017"
maison/cave/chaufferie/temp-eau 23.50
maison/cave/chaufferie/temp-eau 23.69
maison/cave/chaufferie/temp-eau 23.75
maison/cave/chaufferie/temp-eau 23.94
maison/cave/chaufferie/cmd ARRET
maison/cave/chaufferie/temp-eau 24.06
maison/cave/chaufferie/etat ARRET
maison/cave/chaufferie/temp-eau 24.06
maison/cave/chaufferie/temp-eau 24.06
maison/cave/chaufferie/cmd MARCHE
maison/cave/chaufferie/etat MARCHE
maison/cave/chaufferie/temp-eau 24.06
maison/cave/chaufferie/temp-eau 24.06
maison/cave/chaufferie/cmd ARRET
maison/cave/chaufferie/etat ARRET
maison/cave/chaufferie/temp-eau 24.06
maison/cave/chaufferie/temp-eau 24.06
L’activation du switch dans l’interface :
Activation du bloc Switch
Active le relais sur l’objet chaufferie :
Activation du relais (Power Switch Tail) sur l’objet chaufferie
La désactivation du switch dans l’interface :
Désactivation du switch
Désactive également le relais de l’objet :
Désactivation du relais sur l’objet
La mise à jour des informations dans le bloc switch doit attendre la capture de l’information par push-to-db et le rafraîchissement de la page.
Mise à jour de l’information du bloc après recapture de l’état par push-to-db
De nombreuses améliorations peuvent rapidement prendre place dans le projet Dashboard grâce à l’apparition du block_config (dont la valeur peut être modifiée facilement dans l’éditeur de bloc).
•.Exploiter le paramétrage block_config avec les blocs ICON et BIG_TEXT pour :
•.Formater la valeur à afficher. Par exemple, la chaîne de formatage "%s °C" pourrait être utilisée pour afficher la température 24.50 en produisant le texte 24.50 °C.
•.Changer la couleur du bloc (ou la couleur du texte) en fonction de la donnée télémétrique.
•.Changer l’icône du bloc en fonction de la donnée télémétrique.
•.Ajouter des fonctionnalités :
•.Affichage de l’historique sous forme de graphique.
•.Affichage d’une jauge dans un bloc.
•.Affichage d’un graphe dans un bloc.
•.Réorganisation des blocs dans l’interface.
•.Blocs de taille différente (2 fois plus large et/ou 2 fois plus haut).
•.Affichage d’image dans un bloc (en basse résolution, capturée par une caméra et stocké dans un message).
•.Compléter la fonction check_block_config() dans block.js (en exploitant la définition contenue dans block_config_template ).
•.Initialiser le bloc_config dans l’éditeur de bloc (avec un paramétrage par défaut) en fonction du type de bloc.
•.Implémenter le client MQTT JavaScript en lieu et place de la requête AJAX vers /MqttProxyPublish.
Le point ci-dessous reprend des informations provenant des différents chapitres et permettant de réinstaller et redémarrer rapidement le projet. Cette annexe est également reprise sur le dépôt GitHub du projet où elle sera éventuellement amendée.
Avant de débuter l’installation, il faudra :
1. | Installer le système d’exploitation sur le Raspberry Pi et le configurer comme indiqué dans le chapitre Présentation. |
2. | Localiser les sources du projet qui seront réinstallées. Ces sources proviennent soit de l’archive disponible sur le site des Éditions ENI, soit des fichiers téléchargés depuis le dépôt GitHub du projet. |
Installer Mosquitto
sudo apt-get install mosquitto mosquitto-clients
Créez un fichier passwd et ajoutez l’utilisateur Mosquitto pusr103.
sudo mosquitto_passwd -c /etc/mosquitto/passwd pusr103
Le mot de passe utilisé durant la configuration du projet est 21052017.
Modifier la configuration Mosquitto
sudo nano /etc/mosquitto/mosquitto.conf
Et ajoutez-y les lignes :
allow_anonymous false
password_file /etc/mosquitto/passwd
Redémarrer Mosquitto
sudo systemctl stop mosquitto.service
sudo systemctl start mosquitto.service
Ces sources peuvent être récupérées soit depuis la page Informations générales (copie du projet opérée à l’édition de l’ouvrage), soit depuis le dépôt GitHub du projet, où cette copie est également disponible.
Depuis la page Informations générales
Télécharger l’archive depuis le lien. Assurez-vous que l’archive des sources porte le nom LFPYRASPFL.zip.
Une fois l’archive téléchargée, les sources peuvent être extraites à l’aide de la commande :
unzip -e LFPYRASPFL.zip
Une fois le contenu de l’archive extrait, le répertoire utilisateur doit contenir un répertoire nommé la-maison-pythonic avec les sources du projet.
La copie à l’édition du livre (sur GitHub)
Saisir la commande suivante pour récupérer l’archive :
cd ~
wget https://github.com/mchobby/la-maison-pythonic/raw/master/res/
la-maison-pythonic-(master-livre).zip
Une fois l’archive téléchargée, les sources peuvent être extraites à l’aide de la commande :
unzip -e la-maison-pythonic-(master-livre).zip
Une fois le contenu de l’archive extrait, le répertoire utilisateur doit contenir un répertoirela-maison-pythonic avec les sources du projet.
Depuis le dépôt GitHub du projet
Le projet la-maison-pythonic est également disponible sur le dépôt GitHub, ce dernier ayant continué ses évolutions depuis la sortie de l’ouvrage.
Le projet peut être dupliqué dans le répertoire utilisateur à l’aide des commandes :
cd ~
git clone https://github.com/mchobby/la-maison-pythonic.git
Une fois l’opération terminée, le répertoire utilisateur doit contenir un répertoire la-maison-pythonic avec les sources du projet.
Installer push-to-db
cd ~/la-maison-pythonic/python/push-to-db/
./setup.sh
Il est possible que le script soit dans l’impossibilité de créer la base de données (voir message d’erreur en fin de script). Cela est dû au fait que le script attache l’utilisateur pi à un nouveau groupe, mais que cette modification n’est pas instantanément effective. Par conséquent, il faut déconnecter l’utilisateur pi, puis le reconnecter avant de relancer le script une seconde fois.
Tester push-to-db
Testez le script avec les commandes suivantes :
cd ~/la-maison-pythonic/python/push-to-db/
python push-to-db.py
Le script doit afficher différents messages au démarrage et à la réception de messages MQTT. Une fois le bon fonctionnement confirmé, pressez [Ctrl] C pour interrompre le script Python.
Démarrer push-to-db avec systemd
Installez le fichier de configuration.
cp ~/la-maison-pythonic/python/push-to-db/push-to-db.service.sample
/lib/systemd/system/push-to-db.service
sudo chmod 644 /lib/systemd/system/push-to-db.service
Rechargez la configuration de systemd.
sudo systemctl daemon-reload
Activez le service.
sudo systemctl enable push-to-db.service
sudo systemctl start push-to-db.service
Vérifiez que le service est bien démarré correctement.
sudo systemctl status push-to-db.service
Installer le dashboard
cd ~/la-maison-pythonic/python/dashboard/install/
./setup.sh
Copier les bases de données de démonstration
Ce point est optionnel. Il peut être remplacé par la réinstallation des backups à disposition.
Arrêtez le service push-to-db.
sudo systemctl stop push-to-db.service
Copiez les bases de données.
cd ~/la-maison-pythonic/python/dashboard/install/
cd demodb
cp *.db /var/local/sqlite
Redémarrez le service push-to-db.
sudo systemctl start push-to-db.service
Démarrer dashboard avec systemd
Installez le fichier de configuration.
cp ~/la-maison-pythonic/python/dashboard/install/dashboard.service.sample
/lib/systemd/system/dashboard.service
sudo chmod 644 /lib/systemd/system/dashboard.service
Rechargez la configuration de systemd.
sudo systemctl daemon-reload
Activez le service.
sudo systemctl enable dashboard.service
sudo systemctl start dashboard.service
Vérifiez que le service a démarré correctement.
sudo systemctl status dashboard.service
Tester le dashboard
Pour tester le dashboard, il faut démarrer un navigateur internet, puis saisir l’URL correspondant au Raspberry Pi.
Selon la configuration décrite au chapitre Présentation, les URL possibles sont :
•.http://pythonic.local:5000
•.http://192.168.1.210:5000